From 7d59abd6dc3084780251d640e78c88c35391f326 Mon Sep 17 00:00:00 2001 From: Alex Crawford Date: Mon, 23 Jan 2023 15:52:08 -0600 Subject: [PATCH] Update to Version 13.2 Several updates to the way cyclone detection/tracking output is organized -- no changes to core detection/tracking functions. See "Cyclone Tracking Description - Version 13.2.docx" for details. --- ...e Tracking Description - Version 13.2.docx | Bin 0 -> 36152 bytes Version 13_2 Scripts/C17_ExportToCSV_V13.py | 148 + Version 13_2 Scripts/C2_Reprojection6_E5.py | 180 + .../C3_CycloneDetection_13_2.py | 303 ++ Version 13_2 Scripts/C3_SystemDetection_13.py | 94 + ...4_Subset_Crossing_byLatLon_andLength_13.py | 137 + .../C5_CycloneStatSummary_13_V3_AllStorms.py | 220 + ...C6A_Track_Aggregation_Append_v13_events.py | 272 ++ ..._Track_Aggregation_Append_v13_intensity.py | 262 ++ .../C6A_Track_Aggregation_Append_v13_rates.py | 266 ++ ...C6A_Track_Aggregation_Append_v13_trkden.py | 247 ++ .../C6B_Track_Aggregation_Climatology_v13.py | 224 + Version 13_2 Scripts/CycloneModule_13_2.py | 3777 +++++++++++++++++ 13 files changed, 6130 insertions(+) create mode 100644 Cyclone Tracking Description - Version 13.2.docx create mode 100755 Version 13_2 Scripts/C17_ExportToCSV_V13.py create mode 100755 Version 13_2 Scripts/C2_Reprojection6_E5.py create mode 100644 Version 13_2 Scripts/C3_CycloneDetection_13_2.py create mode 100755 Version 13_2 Scripts/C3_SystemDetection_13.py create mode 100755 Version 13_2 Scripts/C4_Subset_Crossing_byLatLon_andLength_13.py create mode 100644 Version 13_2 Scripts/C5_CycloneStatSummary_13_V3_AllStorms.py create mode 100755 Version 13_2 Scripts/C6A_Track_Aggregation_Append_v13_events.py create mode 100755 Version 13_2 Scripts/C6A_Track_Aggregation_Append_v13_intensity.py create mode 100644 Version 13_2 Scripts/C6A_Track_Aggregation_Append_v13_rates.py create mode 100644 Version 13_2 Scripts/C6A_Track_Aggregation_Append_v13_trkden.py create mode 100644 Version 13_2 Scripts/C6B_Track_Aggregation_Climatology_v13.py create mode 100644 Version 13_2 Scripts/CycloneModule_13_2.py diff --git a/Cyclone Tracking Description - Version 13.2.docx b/Cyclone Tracking Description - Version 13.2.docx new file mode 100644 index 0000000000000000000000000000000000000000..3b1d044b1fa3934e197ed6e790e9e17714cbeb33 GIT binary patch literal 36152 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 Built in removal of DpDr and precip columns if present + 11 Apr 2022 --> Fixed a bug in the removal of DpDr and precip columns and unit conversion + 23 Jan 2023 --> Changed order of columns, added year/month/day/hour columns, changed units to be + hPa instead of Pa +Purpose: Export Cyclone objects' data table as CSV files with year, month, and TID in the filename. +''' + +'''******************** +Import Modules +********************''' +print("Loading modules.") +import pandas as pd +import numpy as np +import CycloneModule_13_2 as md +import os +# import pickle5 + +'''******************************************* +Define Variables +*******************************************''' +print("Defining variables") + +vers = "13_2R" +bbox = "" # Use BBox## for subsets +typ = "System" +path = "/Volumes/Cressida/CycloneTracking/tracking"+vers + +# Time Variables +years = range(1950,1950+1) +mos = range(1,12+1) +dateref = [1900,1,1,0,0,0] + +# Cyclone Parameters +# Aggregation Parameters +minls = 1 # minimum lifespan (in days) for a track to be considered +mintl = 1000 # in km for version 11 and beyond +minlat = 0 # minimum latitude that must be acheived at some point in the track + +'''******************************************* +Main Analysis +*******************************************''' +print("Main Analysis") + +# Load parameters +params = pd.read_pickle(path+"/cycloneparams.pkl") +# params = pickle5.load(open(path+"/cycloneparams.pkl",'rb')) +try: + spres = params['spres'] +except: + spres = 100 + +# Set Up Output Directories +try: + os.chdir(path+"/"+bbox+"/CSV"+typ) +except: + os.mkdir(path+"/"+bbox+"/CSV"+typ) + +for y in years: + Y = str(y) + #Create directories + try: + os.chdir(path+"/"+bbox+"/CSV"+typ+"/"+Y) + except: + os.mkdir(path+"/"+bbox+"/CSV"+typ+"/"+Y) + os.chdir(path+"/"+bbox+"/CSV"+typ+"/"+Y) + + for m in mos: + M = md.dd[m-1] + + #Create directories + try: + os.chdir(path+"/"+bbox+"/CSV"+typ+"/"+Y+"/"+M) + except: + os.mkdir(path+"/"+bbox+"/CSV"+typ+"/"+Y+"/"+M) + os.chdir(path+"/"+bbox+"/CSV"+typ+"/"+Y+"/"+M) + +# Write CSV for Systems +if typ == "System": + for y in years: + Y = str(y) + print(Y) + for m in mos: + M = md.dd[m-1] + + # Load data + trs = pd.read_pickle(path+"/"+bbox+"/"+typ+"Tracks"+"/"+Y+"/"+bbox+typ.lower()+"tracks"+Y+M+".pkl") + # trs = pickle5.load(open(path+"/"+bbox+"/"+typ+"Tracks"+"/"+Y+"/"+bbox+typ.lower()+"tracks"+Y+M+".pkl",'rb')) + + # Write CSVs + for tr in trs: + if tr.lifespan() >= minls and tr.trackLength() >= mintl and np.max(tr.data.lat) >= minlat: + trdata = tr.data + if len(np.intersect1d(trdata.columns,['precip','precipArea','DpDr'])) == 3: + trdata = trdata.drop(columns=['precip','precipArea','DpDr']) + + dates = [md.timeAdd(dateref,[0,0,t,0,0,0]) for t in trdata['time']] + + trdata['year'] = np.array([date[0] for date in dates]) + trdata['month'] = np.array([date[1] for date in dates]) + trdata['day'] = np.array([date[2] for date in dates]) + trdata['hour'] = np.array([date[3] for date in dates]) + trdata['p_cent'] = trdata['p_cent'] / 100 # Pa --> hPa + trdata['p_edge'] = trdata['p_edge'] / 100 # Pa --> hPa + trdata['depth'] = trdata['depth'] / 100 # Pa --> hPa + trdata['radius'] = trdata['radius'] * spres # to units of km + trdata['area'] = trdata['area'] * spres * spres # to units of km^2 + trdata['DsqP'] = trdata['DsqP'] / spres / spres * 100 * 100 / 100 # to units of hPa/[100 km]^2 + trdata['p_grad'] = trdata['p_grad'] / 100 * 1000 * 1000 # to units of hPa / [1000 km] + trdata['Dp'] = trdata['Dp'] / 100 # Pa --> hPa + trdata['DpDt'] = trdata['DpDt'] / 100 # Pa/day --> hPa/day + + trdata.to_csv(path+"/"+bbox+"/CSV"+typ+"/"+Y+"/"+M+"/"+typ+vers+bbox+"_"+Y+M+"_"+str(tr.sid)+".csv",index=False,columns=list(trdata.columns[-4:])+list(trdata.columns[:-4])) + +# Write CSV for Cyclones +else: + for y in years: + Y = str(y) + print(Y) + for m in mos: + M = md.dd[m-1] + # trs = pickle5.load(open(path+"/"+bbox+"/"+typ+"Tracks"+"/"+Y+"/"+bbox+typ.lower()+"tracks"+Y+M+".pkl",'rb')) + trs = pd.read_pickle(path+"/"+bbox+"/"+typ+"Tracks"+"/"+Y+"/"+bbox+typ.lower()+"tracks"+Y+M+".pkl") + + for tr in trs: + if tr.lifespan() >= minls and tr.trackLength() >= mintl and np.max(tr.data.lat) >= minlat: + trdata= tr.data + if len(np.intersect1d(trdata.columns,['precip','precipArea','DpDr'])) == 3: + trdata = trdata.drop(columns=['precip','precipArea','DpDr']) + + trdata['year'] = np.array([date[0] for date in dates]) + trdata['month'] = np.array([date[1] for date in dates]) + trdata['day'] = np.array([date[2] for date in dates]) + trdata['hour'] = np.array([date[3] for date in dates]) + trdata['p_cent'] = trdata['p_cent'] / 100 # Pa --> hPa + trdata['p_edge'] = trdata['p_edge'] / 100 # Pa --> hPa + trdata['depth'] = trdata['depth'] / 100 # Pa --> hPa + trdata['radius'] = trdata['radius'] * spres # to units of km + trdata['area'] = trdata['area'] * spres * spres # to units of km^2 + trdata['DsqP'] = trdata['DsqP'] / spres / spres * 100 * 100 / 100 # to units of hPa/[100 km]^2 + trdata['p_grad'] = trdata['p_grad'] / 100 * 1000 * 1000 # to units of hPa / [1000 km] + trdata['Dp'] = trdata['Dp'] / 100 # Pa --> hPa + trdata['DpDt'] = trdata['DpDt'] / 100 # Pa/day --> hPa/day + + trdata.to_csv(path+"/"+bbox+"/CSV"+typ+"/"+Y+"/"+M+"/"+typ+vers+bbox+"_"+Y+M+"_"+str(tr.tid)+".csv",index=False,columns=list(trdata.columns)) diff --git a/Version 13_2 Scripts/C2_Reprojection6_E5.py b/Version 13_2 Scripts/C2_Reprojection6_E5.py new file mode 100755 index 0000000..cf87b71 --- /dev/null +++ b/Version 13_2 Scripts/C2_Reprojection6_E5.py @@ -0,0 +1,180 @@ +''' +Author: Alex Crawford +Date Created: 10 Mar 2019 +Date Modified: 22 Aug 2019 -- Update for Python 3 + 01 Apr 2020 -- Switched output to netCDF instead of GeoTIFF; + no longer dependent on gdal module (start V5) + 19 Oct 2020 -- pulled the map creation out of the for loop + 06 Oct 2021 -- added a wrap-around for inputs that prevents + empty cells from forming along either 180° or + 360° longitude (start V6) + 15 Nov 2022 -- replaced "np.int" with "int" + +Purpose: Reads in netcdf files & reprojects to the NSIDC EASE2 Grid North. +''' + +'''******************** +Import Modules +********************''' +print("Loading modules.") +import os +import numpy as np +from netCDF4 import Dataset +import xesmf as xe +import CycloneModule_12_4 as md + +'''******************** +Define Variables +********************''' +print("Defining variables") + +# File Variables: +ra = "ERA5" +var = "SLP" + +ncvar = "msl" +nctvar = "time" +ncext = '.nc' + +# Time Variables +ymin, ymax = 2022, 2022 +mmin, mmax = 11, 12 +dmin, dmax = 1, 31 + +mons = ["01","02","03","04","05","06","07","08","09","10","11","12"] +dpm = [31,28,31,30,31,30,31,31,30,31,30,31] # days per month (non leap year) +timestep = 3 # in hours +startdate = [1900,1,1] # The starting date for the reanalysis time steps + +# Inputs for reprojection +xsize, ysize = 25000, -25000 # in meters +nx, ny = 720, 720 # number of grid cells; use 180 by 180 for 100 km grid + +# Path Variables +path = "/Volumes/Cressida" +inpath = path+"/"+ra+"/"+var # +outpath = "/Volumes/Cressida/"+ra+"/"+var+"_EASE2_N0_"+str(int(xsize/1000))+"km" # +suppath = path+"/Projections" + +'''******************************************* +Main Analysis +*******************************************''' +print("Main Analysis") + +# Obtain list of nc files: +os.chdir(outpath) +fileList = os.listdir(inpath) +fileList = [f for f in fileList if (f.endswith(ncext) & f.startswith(ra))] + +# Identify the time steps: +ref_netcdf = Dataset(inpath+"/"+fileList[-1]) + +# Create latitude and longitude arrays: +lons = ref_netcdf.variables['longitude'][:] +lats = ref_netcdf.variables['latitude'][:] + +outprjnc = Dataset(suppath+'/EASE2_N0_'+str(int(xsize/1000))+'km_Projection.nc') +outlat = outprjnc['lat'][:].data +outlon = outprjnc['lon'][:].data + +# Close reference netcdf: +ref_netcdf.close() + +# Define Grids as Dictionaries +grid_in = {'lon': np.r_[lons,lons[0]], 'lat': lats} +grid_out = {'lon': outlon, 'lat': outlat} + +# Create Regridder +regridder = xe.Regridder(grid_in, grid_out, 'bilinear') + +print("Step 2. Set up dates of analysis") +years = range(ymin,ymax+1) +mos = range(mmin,mmax+1) +hrs = [h*timestep for h in range(int(24/timestep))] + +ly = md.leapyearBoolean(years) # annual boolean for leap year or not leap year + +# Start the reprojection loop +print("Step 3. Load, Reproject, and Save") +for y in years: + Y = str(y) + + for m in mos: + M = mons[m-1] + + mlist, hlist = [], [] + ncList = [f for f in fileList if Y+M in f] + + if len(ncList) > 1: + print("Multiple files with the date " + Y+M + " -- skipping.") + continue + if len(ncList) == 0: + print("No files with the date " + Y+M + " -- skipping.") + else: + nc = Dataset(inpath+"/"+ncList[0]) + tlist = nc.variables[nctvar][:] + + # Restrict days to those that exist: + if m == 2 and ly[y-ymin] == 1 and dmax > dpm[m-1]: + dmax1 = 29 + elif dmax > dpm[m-1]: + dmax1 = dpm[m-1] + else: + dmax1 = dmax + + # For days that DO exist: + for d in range(dmin,dmax1+1): + timeD = md.daysBetweenDates(startdate,[y,m,d])*24 + + print(" " + Y + " " + M + " " + str(d)) + + for h in hrs: + # Establish Time + timeH = timeD + h + + # Read from netcdf array + inArr = nc.variables[ncvar][np.where(tlist == timeH)[0][0],:,:] + + # Transform data + outArr = regridder(np.c_[inArr,inArr[:,0]]) + outArr[outlat < 0] = np.nan # Limits to Northern Hemisphere + + # Add to list + mlist.append(outArr) + hlist.append(timeH) + + # Write monthly data to netcdf file + ncf = Dataset(ra+"_EASE2_N0_"+str(int(xsize/1000))+"km_"+var+"_Hourly_"+Y+M+".nc", 'w') + ncf.description = 'Mean sea-level pressure from ERA5. Projection specifications\ + for the EASE2 projection (Lambert Azimuthal Equal Area;\ + lat-origin = 90°N, lon-origin=0°, # cols = ' + str(nx) + ',\ + # rows = ' + str(ny) + ', dx = ' + str(xsize) + ', dy = ' + str(ysize) + ', units = meters' + ncf.source = 'netCDF4 python module' + + ncf.createDimension('time', len(mlist)) + ncf.createDimension('x', nx) + ncf.createDimension('y', ny) + ncft = ncf.createVariable('time', int, ('time',)) + ncfx = ncf.createVariable('x', np.float64, ('x',)) + ncfy = ncf.createVariable('y', np.float64, ('y',)) + ncfArr = ncf.createVariable(ncvar, np.float64, ('time','y','x')) + + try: + ncft.units = nc.variables[nctvar].units + except: + ncft.units = 'hours since 1900-01-01 00:00:00.0' + + ncfx.units = 'm' + ncfy.units = 'm' + ncfArr.units = 'Pa' + + # For x and y, note that the upper left point is the edge of the grid cell, but + ## for this we really want the center of the grid cell, hence dividing by 2. + ncft[:] = np.array(hlist) + ncfx[:] = np.arange(-xsize*(nx-1)/2, xsize*(nx-1)/2+xsize, xsize) + ncfy[:] = np.arange(-ysize*(ny-1)/2, ysize*(ny-1)/2+ysize, ysize) + ncfArr[:] = np.array(mlist) + + ncf.close() + +print("Complete.") diff --git a/Version 13_2 Scripts/C3_CycloneDetection_13_2.py b/Version 13_2 Scripts/C3_CycloneDetection_13_2.py new file mode 100644 index 0000000..ba11258 --- /dev/null +++ b/Version 13_2 Scripts/C3_CycloneDetection_13_2.py @@ -0,0 +1,303 @@ +''' +Author: Alex Crawford +Date Created: 20 Jan 2015 +Date Modified: 10 Sep 2020 -> Branch from 12_1 --> kernel size is now based on km instead of cells + 16 Dec 2020 --> updated comments + 13 Jan 2021 --> changed when masking for elevation happens -- after minima are detected, not before + 02 Mar 2022 --> transferred some constants to the module file to save space + --> changed the output for cyclone fields from a + single field per pickled file to to a list of fields for an + entire month in each pickled file + 14 Nov 2022 --> Added an if statement to make this more functional for the Southern Hemisphere + 23 Jan 2023 --> Branch from 12_4 --> added kSizekm to cycloneparams.pkl and switched from "surf" to "field" + +Purpose: Given a series of sea level pressure fields in netcdf files, this + script performs several steps: + 1) Identify closed low pressure centers at each time step + 2) Store information to characterize these centers at each time step + 3) Identify multi-center and single-center cyclone systems + Steps in the tracking part: + 4) Associate each cyclone center with a corresponding center in the + previous time step (when applicable) + 5) Combine the timeseries of cyclone center charcteristics into a data frame for each track + 6) Record cyclone life cycle events (genesis, lysis, splits, merges, secondary genesis) + +User Inputs: paths for inputs, desired projection info, various detection/tracking parameters +''' + +'''******************** +Import Modules +********************''' +# Import clock: +import time +# Start script stopwatch. The clock starts running when time is imported +start = time.perf_counter() + +print("Loading modules") +import os +import copy +import pandas as pd +import numpy as np +import netCDF4 as nc +import CycloneModule_13_2 as md +import warnings + +np.seterr(all='ignore') # This mutes warnings from numpy +warnings.filterwarnings('ignore',category=DeprecationWarning) + +'''******************************************* +Set up Environment +*******************************************''' +print("Setting up environment") +path = "/Volumes/Cressida" +dataset = "ERA5" +verd = "13_2" # Detection Version +vert = 'PTest' # Tracking Version +spres = 100 # Spatial resolution (in km) + +inpath = path+"/"+dataset+"/SLP_EASE2_N0_"+str(spres)+"km" +outpath = path+"/CycloneTracking" +suppath = path+"/Projections/EASE2_N0_"+str(spres)+"km_Projection.nc" + +'''******************** +Define Variables/Parameters +********************''' +print("Defining parameters") +# File Variables +invar = "SLP" +ncvar = "msl" # 'msl' for ERA5, 'SLP' for MERRA2 & CFSR + +# Time Variables +starttime = [1979,1,1,0,0,0] # Format: [Y,M,D,H,M,S] +endtime = [1979,2,1,0,0,0] # stop BEFORE this time (exclusive) +timestep = [0,0,0,6,0,0] # Time step in [Y,M,D,H,M,S] + +dateref = [1900,1,1,0,0,0] # [Y,M,D,H,M,S] + +prior = 0 # 1 = a cyclone track object exists for a prior month; 0 = otherwise + +# Detection Parameters # +minfield = 80000 # minimum reasonable value in field array +maxfield = 200000 # maximum reasonable value in field array + +# Size of kernel (km) used to determine if a grid cell is a local minimum +## Starting in Version 12.1, users enter a distance in km instead of the kernel +## size. This way, the kSize can adapt to different spatial resolutions. The +## equivalent of a 3 by 3 kernel with 100 km resolution would be 100 +## i.e., kSize = (2*kSizekm/spres)+1 +kSizekm = 200 + +# Maximum fraction of neighboring grid cells with no data (Not a Number) allowed +### for a grid cell to be considered during minimum detection +nanThresh = 0.4 + +# minimum slp gradient for identifying (and eliminating) weak minima: +d_slp = 750 # slp difference in Pa (use 0 to turn off) +d_dist = 1000000 # distance in m (units that match units of cellsize) + +# maximum elevation for masking out high elevation minima +maxelev = 1500. # elevation in m (use 10000 to turn off) + +# minimum latitude for masking out the Equator (default should be 5 for NH and -5 for SH) +minlat = 5 + +# Contour interval (Pa; determines the interval needed to identify closed +### contours,and therefore cyclone area) +contint = 200 + +# Multi-center cyclone (mcc) tolerance is the maximum ratio permitted between the +### number of unshared and total contours in a multi-centered cyclone. "Unshared" +### contours are only used by the primary center. "Shared" contours are used +### by both the primary and secondary centers. +mcctol = 0.5 # (use 0 to turn off mcc's; higher makes mcc's more likely) +# Multi-center cyclone (mcc) distance is the maximum distance (in m) two minima can +### lie apart and still be considered part of the same cyclone system +mccdist = 1200000 + +# Tracking Parameters # +# Maximum speed is the fastest that a cyclone center is allowed to travel; given +### in units of km/h. To be realistic, the number should be between 100 and 200. +### and probably over 125 (based on Rudeva et al. 2014). To turn off, set to +### np.inf. Also, note that instabilities occur at temporal resolution of 1-hr. +### Tracking at 6-hr and a maxspeed of 125 km/hr is more comprable to tracking +### at 1-hr and a maxspeed of 300 km/hr (assuming spatial resolution of 50 km). +maxspeed = 150 # constant value +# maxspeed = 150*(3*math.log(timestep[3],6)+2)/timestep[3] # One example of scaling by temporal resolution + +# The reduction parameter is a scaling of cyclone speed. When tracking, the +### algorithm uses the cyclone speed and direction from the last interval to +### estimate a "best guess" position. This parameter modifies that speed, making +### it slower. This reflects how cyclones tend to slow down as they mature. To +### turn off, set to 1. +red = 0.75 + +'''******************************************* +Main Analysis +*******************************************''' +print("Loading Folders & Reference Files") + +##### Ensure that folders exist to store outputs ##### +detpath = outpath+"/detection"+verd +trkpath = outpath+"/tracking"+verd+vert +try: + os.chdir(detpath) +except: + os.mkdir(detpath) + os.chdir(detpath) + os.mkdir("CycloneFields") +try: + os.chdir(trkpath) +except: + os.mkdir(trkpath) + os.chdir(trkpath) + os.mkdir("CycloneTracks") + os.mkdir("ActiveTracks") + os.mkdir("SystemTracks") + +for y in range(starttime[0],endtime[0]+1): + Y = str(y) + + # Cyclone Tracks + try: + os.chdir(trkpath+"/CycloneTracks/"+Y) + except: + os.mkdir(trkpath+"/CycloneTracks/"+Y) + + # Active Tracks + try: + os.chdir(trkpath+"/ActiveTracks/"+Y) + except: + os.mkdir(trkpath+"/ActiveTracks/"+Y) + + # System Tracks + try: + os.chdir(trkpath+"/SystemTracks/"+Y) + except: + os.mkdir(trkpath+"/SystemTracks/"+Y) + +##### Read in attributes of reference files ##### +projnc = nc.Dataset(suppath) + +lats = projnc['lat'][:].data +lons = projnc['lon'][:].data +yDist = projnc['yDistance'][:].data +xDist = projnc['xDistance'][:].data +elev = projnc['z'][:] + +# Generate mask based on latitude and elevation +if minlat >= 0: + mask = np.where((elev > maxelev) | (lats < minlat),np.nan,0) +else: + mask = np.where((elev > maxelev) | (lats > minlat),np.nan,0) + +# Convert kernel size to grid cells +kSize = int(2*kSizekm/spres)+1 + +# Convert max speed to max distance +maxdist = maxspeed*1000*timestep[3] + +# Save Parameters +params = dict({"path":trkpath,"timestep":timestep, "dateref":dateref, "minfield":minfield, + "maxfield":maxfield,"kSize":kSize, "kSizekm":kSizekm,"nanThresh":nanThresh, "d_slp":d_slp, \ + "d_dist":d_dist, "maxelev":maxelev, "minlat":minlat, "contint":contint, + "mcctol":mcctol, "mccdist":mccdist, "maxspeed":maxspeed, "red":red, "spres":spres}) +pd.to_pickle(params,trkpath+"/cycloneparams.pkl") + +##### The actual detection and tracking ##### +print("Cyclone Detection & Tracking") +# Print elapsed time +print(' Elapsed time:',round(time.perf_counter()-start,2),'seconds -- Starting first month') + +# Load netcdf for initial time +ncf = nc.Dataset(inpath+"/"+dataset+"_EASE2_N0_"+str(spres)+"km_"+invar+"_Hourly_"+str(starttime[0])+md.dd[starttime[1]-1]+".nc") +tlist = ncf['time'][:].data + +# Try loading cyclone field objects for the initial month -- if none exist, make an empty list +try: + cflist = pd.read_pickle(detpath+"/CycloneFields/CF"+str(starttime[0])+md.dd[starttime[1]-1]+".pkl") + cftimes = np.array([cf.time for cf in cflist]) +except: + cflistnew = [] + +t = copy.deepcopy(starttime) +while t != endtime: + # Extract date + Y = str(t[0]) + MM = md.mmm[t[1]-1] + M = md.dd[t[1]-1] + date = Y+M+md.dd[t[2]-1]+"_"+md.hhmm[t[3]] + days = md.daysBetweenDates(dateref,t) + + # Load field + try: # If the cyclone field has already been calculated, no need to repeat + cf = cflist[np.where(cftimes == days)[0]] + except: + field = ncf[ncvar][np.where(tlist == md.daysBetweenDates(dateref,t)*24)[0][0],:,:] + field = np.where((field < minfield) | (field > maxfield), np.nan, field) + + # Create a cyclone field object + cf = md.cyclonefield(days) + + # Identify cyclone centers + cf.findCenters(field, mask, kSize, nanThresh, d_slp, d_dist, yDist, xDist, lats, lons) # Identify Cyclone Centers + + # Calculate cyclone areas (and MCCs) + cf.findAreas(field+mask, contint, mcctol, mccdist, lats, lons, kSize) # Calculate Cyclone Areas + + # Append to lists + cflistnew.append( cf ) + + # Track Cyclones + if t == starttime: # If this is the first time step, must initiate tracking + if prior == 0: #If this is the first time step and there are no prior months + ct, cf.cyclones = md.startTracks(cf.cyclones) + + else: #If this is the first time step but there is a prior month + # Identify date/time of prior timestep + tp = md.timeAdd(t,[-i for i in timestep]) + + # Load cyclone tracks and cyclone field from prior time step + ct = pd.read_pickle(trkpath+"/ActiveTracks/"+str(tp[0])+"/activetracks"+str(tp[0])+md.dd[tp[1]-1]+".pkl") + cf1 = pd.read_pickle(detpath+"/CycloneFields/CF"+str(tp[0])+md.dd[tp[1]-1]+".pkl")[-1] # Note, just taking final field from prior month + + md.realignPriorTID(ct,cf1) + + # Start normal tracking + ct, cf = md.trackCyclones(cf1,cf,ct,maxdist,red,timestep[3]) + + else: #If this isn't the first time step, just keep tracking + ct, cf = md.trackCyclones(cf1,cf,ct,maxdist,red,timestep[3]) + + # Increment time step indicator + t = md.timeAdd(t,timestep) + cf1 = copy.deepcopy(cf) + + # Save Tracks & Fields (at the end of each month) + if t[2] == 1 and t[3] == 0: # If the next timestep is the 0th hour of the 1st day of a month, + print(" Exporting Tracks for " + Y + " " + MM + ' -- Elapsed Time: ' + str(round(time.perf_counter()-start,2)) + ' seconds') + start = time.perf_counter() # Reset clock + ct, ct_inactive = md.splitActiveTracks(ct, cf1) + + # Export inactive tracks + pd.to_pickle(ct_inactive,trkpath+"/CycloneTracks/"+Y+"/cyclonetracks"+Y+M+".pkl") + pd.to_pickle(ct,trkpath+"/ActiveTracks/"+Y+"/activetracks"+Y+M+".pkl") + + # Export Cyclone Fields (if new) + try: + pd.to_pickle(cflistnew,detpath+"/CycloneFields/CF"+Y+M+".pkl") + except: + cflist = [] + + if t != endtime: + # Load netcdf for next month + ncf = nc.Dataset(inpath+"/"+dataset+"_EASE2_N0_"+str(spres)+"km_"+invar+"_Hourly_"+str(t[0])+md.dd[t[1]-1]+".nc") + tlist = ncf['time'][:].data + + # Load Cyclone Fields (if they exist) + try: + cflist = pd.read_pickle(detpath+"/CycloneFields/CF"+str(t[0])+md.dd[t[1]-1]+".pkl") + cftimes = np.array([cf.time for cf in cflist]) + except: + cflistnew = [] + +print("Complete") diff --git a/Version 13_2 Scripts/C3_SystemDetection_13.py b/Version 13_2 Scripts/C3_SystemDetection_13.py new file mode 100755 index 0000000..8f48875 --- /dev/null +++ b/Version 13_2 Scripts/C3_SystemDetection_13.py @@ -0,0 +1,94 @@ +''' +Author: Alex Crawford +Date Created: 11 Jan 2016 +Date Modified: 8 Dec 2017, 4 Jun 2019 (Python 3), 13 Jun 2019 (warning added) +Purpose: Convert a series of center tracks to system tracks. Warning: If a) you +wish to re-run this process on some of the data and b) you are using rg = 1 +(allowing regeneration), you need to re-run from the reftime or accept that +some active storms at the re-start point will get truncated. + +User inputs: + Path Variables + Bounding Box ID (subsetnum): 2-digit character string + Time Variables: when to start, end, the time step of the data + Regenesis Paramter: 0 or 1, depending on whether regenesis continues tracks +''' + +'''******************** +Import Modules +********************''' +# Import clock: +from time import perf_counter +# Start script stopwatch. The clock starts running when time is imported +start = perf_counter() + +print("Loading modules.") +import pandas as pd +import CycloneModule_13_2 as md + +'''******************************************* +Set up Environment +*******************************************''' +print("Setting up environment.") +subset = "" # use "" if performing on all cyclones + +inpath = "/Volumes/Cressida/CycloneTracking/tracking13_2R/"+subset +outpath = inpath + +'''******************************************* +Define Variables +*******************************************''' +print("Defining variables") +# Regenesis Paramater +rg = 1 +# 0 = regenesis starts a new system track; +# 1 = regenesis continues previous system track with new ptid + +# Time Variables +starttime = [1950,1,1,0,0,0] # Format: [Y,M,D,H,M,S] +endtime = [1951,1,1,0,0,0] # stop BEFORE this time (exclusive) +reftime = [1950,1,1,0,0,0] +monthstep = [0,1,0,0,0,0] # A Time step that increases by 1 month [Y,M,D,H,M,S] + +dateref = [1900,1,1,0,0,0] # [Y,M,D,H,M,S] + +mons = ["01","02","03","04","05","06","07","08","09","10","11","12"] + +'''******************************************* +Main Analysis +*******************************************''' +print("Main Analysis") + +mt = starttime +while mt != endtime: + # Extract date + Y = str(mt[0]) + M = mons[mt[1]-1] + print (" " + Y + " - " + M) + + # Load Cyclone Tracks + ct = pd.read_pickle(inpath+"/CycloneTracks/"+Y+"/"+subset+"cyclonetracks"+Y+M+".pkl") + + # Create System Tracks + if mt == reftime: + cs, cs0 = md.cTrack2sTrack(ct,[],dateref,rg) + pd.to_pickle(cs,inpath+"/SystemTracks/"+Y+"/"+subset+"systemtracks"+Y+M+".pkl") + + else: + # Extract date for previous month + mt0 = md.timeAdd(mt,[-d for d in monthstep]) + Y0 = str(mt0[0]) + M0 = mons[mt0[1]-1] + + # Load previous month's system tracks + cs0 = pd.read_pickle(inpath+"/SystemTracks/"+Y0+"/"+subset+"systemtracks"+Y0+M0+".pkl") + + # Create system tracks + cs, cs0 = md.cTrack2sTrack(ct,cs0,dateref,rg) + pd.to_pickle(cs,inpath+"/SystemTracks/"+Y+"/"+subset+"systemtracks"+Y+M+".pkl") + pd.to_pickle(cs0,inpath+"/SystemTracks/"+Y0+"/"+subset+"systemtracks"+Y0+M0+".pkl") + + # Increment Time Step + mt = md.timeAdd(mt,monthstep) + +print('Elapsed time:',round(perf_counter()-start,2),'seconds') diff --git a/Version 13_2 Scripts/C4_Subset_Crossing_byLatLon_andLength_13.py b/Version 13_2 Scripts/C4_Subset_Crossing_byLatLon_andLength_13.py new file mode 100755 index 0000000..a97c999 --- /dev/null +++ b/Version 13_2 Scripts/C4_Subset_Crossing_byLatLon_andLength_13.py @@ -0,0 +1,137 @@ +''' +Author: Alex Crawford +Date Created: 28 Jul 2015 +Date Modified: 12 Jun 2019 --> Modified for Python 3 + 18 May 2020 --> Modified for using netcdf files instead of geotiffs + 23 Jan 2023 --> Adapted to version 13 +Purpose: Identify tracks that spend any point of their lifetime within a +bounding box defined by a list of (long,lat) ordered pairs in a csv file. + +Inputs: User must define the... + Type of Tracks (typ) -- Cyclone Centers ("Cyclone") or System Centers ("System") + Bounding Box Number (bboxnum) -- An ID for organizing directories + Bounding Box Mask (bboxName) -- pathway for the mask to be used + Versions of Module and Algorithm Run (e.g. 7.8, 9.5) + Spatial Resolution + Dates of interest + Minimum track length and lifespan +''' + +'''******************** +Import Modules +********************''' +# Import clock: +from time import perf_counter as clock +# Start script stopwatch. The clock starts running when time is imported +start = clock() + +import pandas as pd +import numpy as np +import CycloneModule_13_2 as md +import os +import netCDF4 as nc + +'''******************************************* +Set up Environment +*******************************************''' +path = "/Volumes/Cressida" +inpath = path+"/CycloneTracking/tracking13_2R" +outpath = inpath + +'''******************************************* +Define Variables +*******************************************''' +# File Variables +typ = "System" # Cyclone, System, or Active + +bboxName = path+"/Projections/EASE2_N0_25km_Projection.nc" +bbox = [52,68,-96,-87] # minlat, maxlat, minlon, maxlon +bboxnum = "27" +bboxmain = "" # The main bbox your subsetting from; usually "" for "all cyclones", otherwise BBox## + +# Time Variables +starttime = [1950,1,1,0,0,0] # Format: [Y,M,D,H,M,S] +endtime = [1951,1,1,0,0,0] # stop BEFORE this time (exclusive) +monthstep = [0,1,0,0,0,0] # A Time step that increases by 1 mont [Y,M,D,H,M,S] + +dateref = [1900,1,1,0,0,0] # [Y,M,D,H,M,S] + +months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] +mons = ["01","02","03","04","05","06","07","08","09","10","11","12"] + +minlifespan = 1 # in days +mintracklength = 1000 # in km for version 11+, in gridcells for version 10 + +'''******************************************* +Main Analysis +*******************************************''' +print("Main Analysis") + +# Load Mask +bboxnc = nc.Dataset(bboxName) +lats = bboxnc['lat'][:].data +lons = bboxnc['lon'][:].data + +if bbox[-2] > bbox[-1]: + mask = ((lats >= bbox[0]) & (lats <= bbox[1]) & ( (lons >= bbox[2]) | (lons <= bbox[3])) ) +else: + mask = ((lats >= bbox[0]) & (lats <= bbox[1]) & (lons >= bbox[2]) & (lons <= bbox[3])) + +# Set up output paths +try: + os.chdir(inpath+"/BBox"+bboxnum) +except: + os.mkdir(inpath+"/BBox"+bboxnum) + os.chdir(inpath+"/BBox"+bboxnum) + +try: + os.chdir(inpath+"/BBox"+bboxnum+"/"+typ+"Tracks") +except: + os.mkdir(inpath+"/BBox"+bboxnum+"/"+typ+"Tracks") + os.chdir(inpath+"/BBox"+bboxnum+"/"+typ+"Tracks") + +# Main Loop +mt = starttime +while mt != endtime: + # Extract date + Y = str(mt[0]) + MM = months[mt[1]-1] + M = mons[mt[1]-1] + print(" " + Y + " - " + MM) + + # Load Tracks + cs = pd.read_pickle(inpath+"/"+bboxmain+"/"+typ+"Tracks/"+Y+"/"+bboxmain+typ.lower()+"tracks"+Y+M+".pkl") + cs = [tr for tr in cs if ((tr.lifespan() > minlifespan) and (tr.trackLength() >= mintracklength))] + + trs = [] + for tr in cs: # For each track + # Collect lats and longs + xs = list(tr.data.x) + ys = list(tr.data.y) + + # Prep while loop + test = 0 + i = 0 + while test == 0 and i < len(xs): + # If at any point the cyclone enters the bbox, keep it + if mask[int(ys[i]),int(xs[i])] == 1: + trs.append(tr) + test = 1 + else: + i = i+1 + + # Save Tracks + try: + os.chdir(inpath+"/BBox"+bboxnum+"/"+typ+"Tracks/"+Y) + except: + os.mkdir(inpath+"/BBox"+bboxnum+"/"+typ+"Tracks/"+Y) + os.chdir(inpath+"/BBox"+bboxnum+"/"+typ+"Tracks/"+Y) + + pd.to_pickle(trs,"BBox"+bboxnum+typ.lower()+"tracks"+Y+M+".pkl") + + # Increment Month + mt = md.timeAdd(mt,monthstep) + +# Print elapsed time +print('Elapsed time:',round(clock()-start,2),'seconds') +print("Complete") diff --git a/Version 13_2 Scripts/C5_CycloneStatSummary_13_V3_AllStorms.py b/Version 13_2 Scripts/C5_CycloneStatSummary_13_V3_AllStorms.py new file mode 100644 index 0000000..a6d0088 --- /dev/null +++ b/Version 13_2 Scripts/C5_CycloneStatSummary_13_V3_AllStorms.py @@ -0,0 +1,220 @@ +''' +Author: Alex Crawford +Date Created: 11 Mar 2015 +Date Modified: 21 Jun 2018 -> Switch to iloc, loc method of subsetting +12 Jun 2019 --> Update for Python 3 +19 May 2020 --> Added automatic creation of output directory +02 Jul 2020 --> Removed reliance on GDAL; using regions stored in netcdf file +21 Jan 2021 --> Added dispalcement as subset option; added genesis/lysis region check +06 Oct 2021 --> Replaced pickled regions file with a netcdf +Purpose: Records information that summarizes each track (e.g., length, lifespan, +region of origin, number of merges and splits). +''' + +'''******************** +Import Modules +********************''' + +#print "Loading modules." +import pandas as pd +import os +import numpy as np +import netCDF4 as nc +import CycloneModule_13_2 as md + +'''******************************************* +Set up Environment +*******************************************''' +BBoxNum = "BBox27" # Use "BBox##" or "" if no subset +path = "/Volumes/Cressida" +version = "13_2R" +inpath = path+"/CycloneTracking/tracking"+version+"/"+BBoxNum +regpath = path+"/Projections/EASE2_N0_25km_GenesisRegions.nc" + +'''******************************************* +Define Variables +*******************************************''' +# File Variables +ext = ".tif" +kind1 = "System" # System, Cyclone +kind = kind1+"Tracks" # Can be AFZ, Arctic, or other region (or no region), followed by System or Cyclone + +rg = 1 # Whether regenesis of a cyclone counts as a track split (0) or track continuation (1) + +V = "_GenReg" # An optional version name; suggested to start with "_" or "-" to separate from years in file title + +# Aggregation Parameters +minls = 1 # minimum lifespan (in days) for a track to be considered +mintl = 1000 # minimum track length (in km) +mindisp = 0 +minlat = 0 # minimum latitude + +# Time Variables +starttime = [1950,1,1,0,0,0] # Format: [Y,M,D,H,M,S] +endtime = [1980,1,1,0,0,0] # stop BEFORE this time (exclusive) +monthstep = [0,1,0,0,0,0] # A Time step that increases by 1 mont [Y,M,D,H,M,S] + +dateref = [1900,1,1,0,0,0] # [Y,M,D,H,M,S] + +months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] +mons = ["01","02","03","04","05","06","07","08","09","10","11","12"] + +'''******************************************* +Main Analysis +*******************************************''' +print("Main Analysis") +# Load Regions +regs = nc.Dataset(regpath)['reg'][:].data +spres = pd.read_pickle(path+"/CycloneTracking/tracking"+version+"/cycloneparams.pkl")['spres'] + +# Create Empty lists +sid, tid, year, month, avguv, maxdsqp, minp, maxdepth, maxpgrad, avgdsqp, avgp, avgdepth, avgpgrad = [[] for i in range(13)] +lifespan, trlen, avgarea, mcc, spl1, spl2, spl3, mrg1, mrg2, mrg3, rge2, genReg, lysReg = [[] for i in range(13)] + +mt = starttime +while mt != endtime: + # Extract date + Y = str(mt[0]) + MM = months[mt[1]-1] + M = mons[mt[1]-1] + print (" " + Y + " - " + MM ) + + mtdays = md.daysBetweenDates(dateref,mt) # Convert date to days since [1900,1,1,0,0,0] + mt0 = md.timeAdd(mt,[-i for i in monthstep]) # Identify time for the previous month + mt2 = md.timeAdd(mt,monthstep) # Identify time for next month + + # Load tracks + cs = pd.read_pickle(inpath+"/"+kind+"/"+Y+"/"+BBoxNum+kind.lower()+Y+M+".pkl") # Current Month + try: # Previous Month + cs0 = pd.read_pickle(inpath+"/"+kind+"/"+str(mt0[0])+"/"+BBoxNum+kind.lower()+str(mt0[0])+mons[mt0[1]-1]+".pkl") + except: + cs0 = [] + try: # Next Month + cs2 = pd.read_pickle(inpath+"/"+kind+"/"+str(mt2[0])+"/"+BBoxNum+kind.lower()+str(mt2[0])+mons[mt2[1]-1]+".pkl") + cs2 = [c for c in cs2 if np.isnan(c.ftid) == 0] # Only care about tracks that existed in current month + except: # For final month in series, forced to used active tracks for partial tabulation of events + try: + cs2 = pd.read_pickle(inpath+"/ActiveTracks/"+str(mt[0])+"/"+BBoxNum+"activetracks"+str(mt[0])+mons[mt[1]-1]+".pkl") + cs2, cs = md.cTrack2sTrack(cs2,cs,dateref,rg) + except: + cs2 = [] + + # Limit to tracks that satisfy minimum lifespan and track length + trs = [c for c in cs if ((c.lifespan() > minls) and (c.trackLength() >= mintl) and (np.max(c.data.lat) >= minlat) and (c.maxDistFromGenPnt() >= mindisp))] + trs0 = [c for c in cs0 if ((c.lifespan() > minls) and (c.trackLength() >= mintl) and (np.max(c.data.lat) >= minlat) and (c.maxDistFromGenPnt() >= mindisp))] + trs2 = [c for c in cs2 if ((c.lifespan() > minls) and (c.trackLength() >= mintl) and (np.max(c.data.lat) >= minlat) and (c.maxDistFromGenPnt() >= mindisp))] + + ### EVENT FIELDS ### + # Limit events to only those tracks that satisfy above criteria + tids = [tr.tid for tr in trs] + ftids = [tr.ftid for tr in trs] + tids0 = [tr.tid for tr in trs0] + ftids2 = [tr.ftid for tr in trs2] + + for tr in trs: # For each track + # Establish event counters + mrg1_count, mrg2_count, mrg3_count = 0, 0, 0 + spl1_count, spl2_count, spl3_count = 0, 0, 0 + rge2_count = 0 + for e in range(len(tr.events)): # Check the stats for each event + # Only record the event if the interacting track also satisfies the lifespan/track length criteria + # If the event time occurs during the month of interest... + # Check if the otid track exists in either this month or the next month: + if tr.events.time.iloc[e] >= mtdays and ( (tr.events.otid.iloc[e] in tids) or (tr.events.otid.iloc[e] in ftids2) ): + # And if so, record the event type + if tr.events.event.iloc[e] == "mg": + if tr.events.Etype.iloc[e] == 1: + mrg1_count = mrg1_count + 1 + elif tr.events.Etype.iloc[e] == 2: + mrg2_count = mrg2_count + 1 + elif tr.events.Etype.iloc[e] == 3: + mrg3_count = mrg3_count + 1 + elif tr.events.event.iloc[e] == "sp": + if tr.events.Etype.iloc[e] == 1: + spl1_count = spl1_count + 1 + elif tr.events.Etype.iloc[e] == 2: + spl2_count = spl2_count + 2 + elif tr.events.Etype.iloc[e] == 3: + spl3_count = spl3_count + 3 + + # If the event time occurs during the previous month... + # Check if the otid track exists in either this month or the previous month: + elif tr.events.time.iloc[e] < mtdays and ( (tr.events.otid.iloc[e] in tids0) or (tr.events.otid.iloc[e] in ftids) ): + # And if so, record the event type + if tr.events.event.iloc[e] == "mg": + if tr.events.Etype.iloc[e] == 1: + mrg1_count = mrg1_count + 1 + elif tr.events.Etype.iloc[e] == 2: + mrg2_count = mrg2_count + 1 + elif tr.events.Etype.iloc[e] == 3: + mrg3_count = mrg3_count + 1 + elif tr.events.event.iloc[e] == "sp": + if tr.events.Etype.iloc[e] == 1: + spl1_count = spl1_count + 1 + elif tr.events.Etype.iloc[e] == 2: + spl2_count = spl2_count + 2 + elif tr.events.Etype.iloc[e] == 3: + spl3_count = spl3_count + 3 + + + # Append to lists + try: + sid.append(tr.sid) + except: + tid.append(tr.tid) + + year.append(mt[0]), month.append(mt[1]), avguv.append( tr.data.uv.mean() ) + maxdsqp.append( tr.data.DsqP.max() ), minp.append( tr.data.p_cent.min() ) + maxdepth.append( tr.data.depth.max() ), maxpgrad.append( tr.data.p_grad.max() ) + avgdsqp.append( tr.data.DsqP.mean() ), avgpgrad.append( tr.data.p_grad.mean() ) + avgp.append( tr.data.p_cent.mean() ), avgdepth.append( tr.data.depth.mean() ) + lifespan.append( tr.lifespan() ), trlen.append( tr.trackLength() ) + avgarea.append( tr.avgArea() ), mcc.append( tr.mcc() ), rge2.append( rge2_count ) + spl1.append( spl1_count ), spl2.append( spl2_count ), spl3.append( spl3_count ) + mrg1.append( mrg1_count ), mrg2.append( mrg2_count ), mrg3.append( mrg3_count ) + genReg.append( regs[list(tr.data.loc[tr.data.type > 0,'y'])[0],list(tr.data.loc[tr.data.type > 0,'x'])[0]] ) + lysReg.append( regs[list(tr.data.y)[-1],list(tr.data.x)[-1]] ) + + mt = md.timeAdd(mt,monthstep) + +# Construct Pandas Dataframe +if kind1 == 'System': + pdf = pd.DataFrame({"sid":sid,"year":year,"month":month,"avguv":avguv,\ + "maxdsqp":maxdsqp,"minp":minp,"maxdepth":maxdepth,"maxpgrad":maxpgrad,\ + "avgdsqp":avgdsqp,"avgp":avgp,"avgdepth":avgdepth,"avgpgrad":avgpgrad,\ + "lifespan":lifespan,"trlen":trlen,"avgarea":avgarea,"mcc":mcc,\ + "spl1":spl1,"spl2":spl2,"spl3":spl3,"mrg1":mrg1,"mrg2":mrg2,"mrg3":mrg3,\ + "rge2":rge2,"genReg":genReg,"lysReg":lysReg}) +else: + pdf = pd.DataFrame({"tid":tid,"year":year,"month":month,"avguv":avguv,\ + "maxdsqp":maxdsqp,"minp":minp,"maxdepth":maxdepth,"maxpgrad":maxpgrad,\ + "avgdsqp":avgdsqp,"avgp":avgp,"avgdepth":avgdepth,"avgpgrad":avgpgrad,\ + "lifespan":lifespan,"trlen":trlen,"avgarea":avgarea,"mcc":mcc,\ + "spl1":spl1,"spl2":spl2,"spl3":spl3,"mrg1":mrg1,"mrg2":mrg2,"mrg3":mrg3,\ + "rge2":rge2,"genReg":genReg,"lysReg":lysReg}) + +# Append columns for overall merge, split, non-interacting +pdf["mrg"] = np.array((pdf["mrg2"] >= 1) | (pdf["mrg3"] >= 1)).astype(int) +pdf["spl"] = np.array((pdf["spl2"] >= 1) | (pdf["spl3"] >= 1)).astype(int) +pdf["nonint"] = np.array((pdf["mrg"] == 0) & (pdf["spl"] == 0)).astype(int) + +# Convert units +pdf['avgp'] = pdf['avgp']/100 # Pa --> hPa +pdf['minp'] = pdf['minp']/100 # Pa --> hPa +pdf['avgdepth'] = pdf['avgdepth']/100 # Pa --> hPa +pdf['maxdepth'] = pdf['maxdepth']/100 # Pa --> hPa +pdf['avgpgrad'] = pdf['avgpgrad']/100*1000*1000 # Pa/m --> hPa/[1000 km] +pdf['maxpgrad'] = pdf['maxpgrad']/100*1000*1000 # Pa/m --> hPa/[1000 km] +pdf['avgdsqp'] = pdf['avgdsqp']/spres/spres*100 # Pa/gridcell^2 --> hPa/[100 km]^2 (1/100*100*100 = 100) +pdf['maxdsqp'] = pdf['maxdsqp']/spres/spres*100 # Pa/gridcell^2 --> hPa/[100 km]^2 (1/100*100*100 = 100) +pdf['avgarea'] = pdf['avgarea']*spres*spres # gridcell^2 --> km^2 + +# Write to File +try: + os.chdir(inpath+"/Aggregation"+kind1) +except: + os.mkdir(inpath+"/Aggregation"+kind1) + os.chdir(inpath+"/Aggregation"+kind1) + +YY = str(starttime[0]) + "_" + str(md.timeAdd(endtime,[0,-1,0,0,0,0])[0]) +pdf.to_csv(BBoxNum+"_"+kind+"Events_"+version+"_"+YY+V+".csv",index=False) diff --git a/Version 13_2 Scripts/C6A_Track_Aggregation_Append_v13_events.py b/Version 13_2 Scripts/C6A_Track_Aggregation_Append_v13_events.py new file mode 100755 index 0000000..ed57502 --- /dev/null +++ b/Version 13_2 Scripts/C6A_Track_Aggregation_Append_v13_events.py @@ -0,0 +1,272 @@ +''' +Author: Alex Crawford +Date Created: 10 Mar 2015 +Date Modified: 18 Apr 2016; 10 Jul 2019 (update for Python 3); + 10 Sep 2020 (switch from geotiff to netcdf), switch to uniform_filter from scipy.ndimage + 30 Sep 2020 (switch back to slower custom smoother because of what scipy does to NaNs) + 18 Feb 2021 (edited seasonal caluclations to work directly from months, not monthly climatology, + allowing for cross-annual averaging) + 13 Sep 2021: If a pre-existing file exists, this script will append new results + instead of overwriting for all years. Climatologies no longer in this script. + 01 Nov 2021: Added the possibility of appending prior years as will as subsequent years. + 23 Jan 2023: Adapted to version 13 + +Purpose: Calculate aggergate statistics (Eulerian and Lagrangian) for either +cyclone tracks or system tracks. + +User inputs: + Path Variables, including the reanalysis (ERA, MERRA, CFSR) + Track Type (typ): Cyclone or System + Bounding Box ID (bboxnum): 2-digit character string + Time Variables: when to start, end, the time step of the data + Aggregation Parameters (minls, mintl, kSizekm) + +Other notes: + Units for track density are tracks/month/gridcell + Units for event counts are raw counts (#/month/gridcell) + Units for counts relative to cyclone obs are ratios (%/gridcell/100) +''' + +'''******************** +Import Modules +********************''' +# Import clock: +from time import perf_counter as clock +start = clock() +import warnings +warnings.filterwarnings("ignore") + +print("Loading modules.") +import os +import pandas as pd +from scipy import ndimage +import numpy as np +import netCDF4 as nc +import CycloneModule_13_2 as md +# import pickle5 + +'''******************************************* +Set up Environment +*******************************************''' +print("Setting up environment.") +bboxnum = "BBox27" # use "" if performing on all cyclones; or BBox## +typ = "System" +ver = "13_2R" + +path = "/Volumes/Cressida" +inpath = path+"/CycloneTracking/tracking"+ver +outpath = inpath+"/"+bboxnum +suppath = path+"/Projections" + +'''******************************************* +Define Variables +*******************************************''' +print("Defining variables") +# Time Variables +starttime = [1950,1,1,0,0,0] # Format: [Y,M,D,H,M,S] +endtime = [1951,1,1,0,0,0] # stop BEFORE this time (exclusive) +monthstep = [0,1,0,0,0,0] # A Time step that increases by 1 month [Y,M,D,H,M,S] + +dateref = [1900,1,1,0,0,0] # [Y,M,D,H,M,S] + +seasons = np.array([1,2,3,4,5,6,7,8,9,10,11,12]) # Ending month for three-month seasons (e.g., 2 = DJF) +months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] +mons = ["01","02","03","04","05","06","07","08","09","10","11","12"] +dpm = [31,28,31,30,31,30,31,31,30,31,30,31] + +# Aggregation Parameters +minls = 1 # minimum lifespan (in days) for a track to be considered +mintl = 1000 # minimum track length (in km for version ≥ 11.1; grid cells for version ≤ 10.10) for a track to be considered +kSizekm = 800 # Full kernel size (in km) for spatial averaging measured between grid cell centers. + ## For a 100 km spatial resolution, 400 is a 4 by 4 kernel; i.e., kSize = (kSizekm/spres) + +# Variables +vNames = ["countA","gen","lys","spl","mrg"] +varsi = range(1,len(vNames)) # range(0,1) # +vunits = ['ratio','count','count','count','count'] +agg = [-1,-1,-1,-1,-1] + +'''******************************************* +Main Analysis +*******************************************''' +print("Main Analysis") +# Ensure that folders exist to store outputs +try: + os.chdir(outpath+"/Aggregation"+typ+"/"+str(kSizekm)+"km") +except: + os.mkdir(outpath+"/Aggregation"+typ+"/"+str(kSizekm)+"km") + os.chdir(outpath+"/Aggregation"+typ+"/"+str(kSizekm)+"km") +priorfiles = os.listdir() + +print("Step 1. Load Files and References") +# Read in attributes of reference files +params = pd.read_pickle(inpath+"/cycloneparams.pkl") +# params = pickle5.load(open(inpath+"/cycloneparams.pkl",'rb')) +timestep = params['timestep'] +spres = params['spres'] + +proj = nc.Dataset(suppath+"/EASE2_N0_"+str(spres)+"km_Projection.nc") +lats = proj['lat'][:] + +kSize = int(kSizekm/spres) # This needs to be the full width ('diameter'), not the half width ('radius') for ndimage filters + +print("Step 2. Aggregation requested for " + str(starttime[0]) + "-" + str(endtime[0]-1)) +startyears, endyears = [starttime[0] for i in vNames], [endtime[0] for i in vNames] +firstyears, nextyears = [starttime[0] for i in vNames], [endtime[0] for i in vNames] +for v in varsi: + name = ver+"_AggregationFields_Monthly_"+vNames[v]+".nc" + if name in priorfiles: + prior = nc.Dataset(name) + + nextyears[v] = int(np.ceil(prior['time'][:].max())) + firstyears[v] = int(np.floor(prior['time'][:].min())) + + if starttime[0] < firstyears[v]: # If the desired time range starts before the prior years... + if endtime[0] >= firstyears[v]: + startyears[v], endyears[v] = starttime[0], firstyears[v] + else: + raise Exception("There is a gap between the ending year requested ("+str(endtime[0]-1)+") and the first year already aggregated ("+str(firstyears[v])+"). Either increase the ending year or choose a different destination folder.") + elif endtime[0] > nextyears[v]: # If the desired range ends after the prior years... + if starttime[0] <= nextyears[v]: + startyears[v], endyears[v] = nextyears[v], endtime[0] + else: + raise Exception("There is a gap between the last year already aggregated ("+str(nextyears[v]-1)+") and the starting year requested ("+str(starttime[0])+"). Either decrease the starting year or choose a different destination folder.") + else: + raise Exception("All requested years are already aggregated.") + else: + startyears[0], endyears[0] = starttime[0], endtime[0] + +# Start at the earliest necessary time for ALL variables of interest +newstarttime = [np.min(np.array(startyears)[varsi]),1,1,0,0,0] +newendtime = [np.max(np.array(endyears)[varsi]),1,1,0,0,0] + +print("Some years may have already been aggregated.\nAggregating for " + str(newstarttime[0]) + "-" + str(newendtime[0]-1) + ".") + +vlists = [ [] for v in vNames] + +mt = newstarttime +while mt != newendtime: + # Extract date + Y = str(mt[0]) + MM = months[mt[1]-1] + M = mons[mt[1]-1] + print(" " + Y + " - " + MM) + + mtdays = md.daysBetweenDates(dateref,mt,lys=1) # Convert date to days since [1900,1,1,0,0,0] + mt0 = md.timeAdd(mt,[-i for i in monthstep],lys=1) # Identify time for the previous month + + # Define number of valid times for making %s from counting stats + if MM == "Feb" and md.leapyearBoolean(mt)[0] == 1: + n = 29*(24/timestep[3]) + else: + n = dpm[mt[1]-1]*(24/timestep[3]) + + ### LOAD TRACKS ### + # Load Cyclone/System Tracks + cs = pd.read_pickle(inpath+"/"+bboxnum+"/"+typ+"Tracks/"+Y+"/"+bboxnum+typ.lower()+"tracks"+Y+M+".pkl") + # cs = pickle5.load(open(inpath+"/"+bboxnum+"/"+typ+"Tracks/"+Y+"/"+bboxnum+typ.lower()+"tracks"+Y+M+".pkl",'rb')) + try: + cs0 = pd.read_pickle(inpath+"/"+bboxnum+"/"+typ+"Tracks/"+str(mt0[0])+"/"+bboxnum+typ.lower()+"tracks"+str(mt0[0])+mons[mt0[1]-1]+".pkl") + # cs0 = pickle5.load(open(inpath+"/"+bboxnum+"/"+typ+"Tracks/"+str(mt0[0])+"/"+bboxnum+typ.lower()+"tracks"+str(mt0[0])+mons[mt0[1]-1]+".pkl",'rb')) + except: + cs0 = [] + # Load Active tracks + ct2 = pd.read_pickle(inpath+"/ActiveTracks/"+Y+"/activetracks"+Y+M+".pkl") + # ct2 = pickle5.load(open(inpath+"/ActiveTracks/"+Y+"/activetracks"+Y+M+".pkl",'rb')) + if typ == "Cyclone": + cs2 = ct2 + else: + try: # Convert active tracks to systems as well + cs2 = md.cTrack2sTrack(ct2,[],dateref,1)[0] + except: + cs2 = [] + + ### LIMIT TRACKS & IDS ### + # Limit to tracks that satisfy minimum lifespan and track length + trs = [c for c in cs if ((c.lifespan() > minls) and (c.trackLength() >= mintl))] + trs2 = [c for c in cs2 if ((c.lifespan() > minls) and (c.trackLength() >= mintl))] + trs0 = [c for c in cs0 if ((c.lifespan() > minls) and (c.trackLength() >= mintl))] + + ### CALCULATE FIELDS ### + fields0 = [np.nan] + fields1 = md.aggregateEvents([trs,trs0,trs2],typ,mtdays,lats.shape) + + fields = fields0 + fields1 + + ### SMOOTH FIELDS ### + for v in varsi: + # varFieldsm = md.smoothField(fields[v],kSize) # Smooth + varFieldsm = ndimage.uniform_filter(fields[v],kSize,mode="nearest") # --> This cannot handle NaNs + vlists[v].append(varFieldsm) # append to list + + # Increment Month + mt = md.timeAdd(mt,monthstep,lys=1) + +### SAVE FILE ### +print("Step 3. Write to NetCDF") +for v in varsi: + print(vNames[v]) + mnc = nc.Dataset(ver+"_AggregationFields_Monthly_"+vNames[v]+"_NEW.nc",'w') + mnc.createDimension('y', lats.shape[0]) + mnc.createDimension('x', lats.shape[1]) + mnc.createDimension('time', (max(nextyears[v],newendtime[0])-min(firstyears[v],newstarttime[0]))*12) + mnc.description = 'Aggregation of cyclone track ' + vNames[v] + ' on monthly time scale.' + + ncy = mnc.createVariable('y', np.float32, ('y',)) + ncx = mnc.createVariable('x', np.float32, ('x',)) + ncy.units, ncx.units = 'm', 'm' + ncy[:] = np.arange(proj['lat'].shape[0]*spres*1000/-2 + (spres*1000/2),proj['lat'].shape[0]*spres*1000/2, spres*1000) + ncx[:] = np.arange(proj['lat'].shape[1]*spres*1000/-2 + (spres*1000/2),proj['lat'].shape[1]*spres*1000/2, spres*1000) + + # Add times, lats, and lons + nctime = mnc.createVariable('time', np.float32, ('time',)) + nctime.units = 'years' + nctime[:] = np.arange(min(firstyears[v],newstarttime[0]),max(nextyears[v],newendtime[0]),1/12) + + nclon = mnc.createVariable('lon', np.float32, ('y','x')) + nclon.units = 'degrees' + nclon[:] = proj['lon'][:] + + nclat = mnc.createVariable('lat', np.float32, ('y','x')) + nclat.units = 'degrees' + nclat[:] = proj['lat'][:] + + vout = np.array(vlists[v]) + vout = np.where(vout == 0,np.nan,vout) + ncvar = mnc.createVariable(vNames[v], np.float64, ('time','y','x')) + ncvar.units = vunits[v] + ' -- Smoothing:' + str(kSizekm) + ' km' + + name = ver+"_AggregationFields_Monthly_"+vNames[v]+".nc" + if name in priorfiles: # Append data if prior data existed... + if vout.shape[0] > 0: # ...and there is new data to be added + prior = nc.Dataset(name) + + if (newstarttime[0] <= firstyears[v]) and (newendtime[0] >= nextyears[v]): # If the new data starts before and ends after prior data + ncvar[:] = vout + + elif (newstarttime[0] > firstyears[v]) and (newendtime[0] < nextyears[v]): # If the new data starts after and ends before prior data + ncvar[:] = np.concatenate( ( prior[vNames[v]][prior['time'][:].data < newstarttime[0],:,:].data , vout , prior[vNames[v]][prior['time'][:].data >= newendtime[0],:,:].data ) ) + + elif (newendtime[0] <= firstyears[v]): # If the new data starts and ends before the prior data + ncvar[:] = np.concatenate( ( vout , prior[vNames[v]][prior['time'][:].data >= newendtime[0],:,:].data ) ) + + elif (newstarttime[0] >= nextyears[v]): # If the new data starts and ends after the prior data + ncvar[:] = np.concatenate( ( prior[vNames[v]][prior['time'][:].data < newstarttime[0],:,:].data , vout ) ) + + else: + mnc.close() + raise Exception("Times are misaligned. Requested Time Range: " + str(starttime) + "-" + str(endtime) + ". Processed Time Range: " + str(newstarttime) + "-" + str(newendtime) + ".") + + prior.close(), mnc.close() + os.remove(name) # Remove old file + os.rename(ver+"_AggregationFields_Monthly_"+vNames[v]+"_NEW.nc", name) # rename new file to standard name + + else: # Create new data if no prior data existed + ncvar[:] = vout + mnc.close() + os.rename(ver+"_AggregationFields_Monthly_"+vNames[v]+"_NEW.nc", name) # rename new file to standard name + +if (newendtime[0] < endtime[0]) & (max(nextyears) < endtime[0]): + print("Completed aggregating " + str(newstarttime[0]) + "-" + str(newendtime[0]-1)+".\nRe-run this script to aggregate any time after " + str(max(nextyears[v],newendtime[0])-1) + ".") +else: + print("Completed aggregating " + str(newstarttime[0]) + "-" + str(newendtime[0]-1)+".") diff --git a/Version 13_2 Scripts/C6A_Track_Aggregation_Append_v13_intensity.py b/Version 13_2 Scripts/C6A_Track_Aggregation_Append_v13_intensity.py new file mode 100755 index 0000000..ed6c233 --- /dev/null +++ b/Version 13_2 Scripts/C6A_Track_Aggregation_Append_v13_intensity.py @@ -0,0 +1,262 @@ +''' +Author: Alex Crawford +Date Created: 10 Mar 2015 +Date Modified: 22 Feb 2021: Adpated from prior C4 scripts to work with just intensity measures + 13 Sep 2021: If a pre-existing file exists, this script will append new results + instead of overwriting for all years. Climatologies no longer in this script. + 01 Nov 2021: Added the possibility of appending prior years as will as subsequent years. + 23 Jan 2023: Adapted to version 13 + +Purpose: Calculate aggergate intensity statistics for either cyclone tracks +or system tracks. + +User inputs: + Path Variables, including the reanalysis (ERA, MERRA, CFSR) + Track Type (typ): Cyclone or System + Bounding Box ID (bboxnum): 2-digit character string + Time Variables: when to start, end, the time step of the data + Aggregation Parameters (minls, mintl, kSizekm) + +''' + +'''******************** +Import Modules +********************''' +# Import clock: +from time import perf_counter as clock +start = clock() +import warnings +warnings.filterwarnings("ignore") + +print("Loading modules.") +import os +import pandas as pd +# import pickle5 +from scipy import ndimage +import numpy as np +import netCDF4 as nc +import CycloneModule_13_2 as md + +'''******************************************* +Set up Environment +*******************************************''' +print("Setting up environment.") +bboxnum = "BBox27" # use "" if performing on all cyclones; or BBox## +typ = "System" +ver = "13_2R" + +path = "/Volumes/Cressida" +inpath = path+"/CycloneTracking/tracking"+ver +outpath = inpath+"/"+bboxnum +suppath = path+"/Projections" + +'''******************************************* +Define Variables +*******************************************''' +print("Defining variables") +# Time Variables +starttime = [1950,1,1,0,0,0] # Format: [Y,M,D,H,M,S] +endtime = [1951,1,1,0,0,0] # stop BEFORE this time (exclusive) +monthstep = [0,1,0,0,0,0] # A Time step that increases by 1 month [Y,M,D,H,M,S] + +dateref = [1900,1,1,0,0,0] # [Y,M,D,H,M,S] + +seasons = np.array([1,2,3,4,5,6,7,8,9,10,11,12]) # Ending month for three-month seasons (e.g., 2 = DJF) +months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] +mons = ["01","02","03","04","05","06","07","08","09","10","11","12"] +dpm = [31,28,31,30,31,30,31,31,30,31,30,31] + +# Aggregation Parameters +minls = 1 # minimum lifespan (in days) for a track to be considered +mintl = 1000 # minimum track length (in km for version ≥ 11.1; grid cells for version ≤ 10.10) for a track to be considered +kSizekm = 800 # Full kernel size (in km) for spatial averaging measured between grid cell centers. + ## For a 100 km spatial resolution, 400 is a 4 by 4 kernel; i.e., kSize = (kSizekm/spres) + +# Read in attributes of reference files +params = pd.read_pickle(inpath+"/cycloneparams.pkl") +# params = pickle5.load(open(inpath+"/cycloneparams.pkl",'rb')) +timestep = params['timestep'] +try: + spres = params['spres'] +except: + spres = 100 + +# Variables (Note that countP is mandatory) +vNames = ["countP"] + ["p_cent","depth","DsqP","p_grad","radius"] +varsi = [0] + [1,2,3,4] +multiplier = [1] + [0.01,0.01,100/spres/spres,1000*1000/100,spres] +# Note, for Laplacian, this is really *100*100/100/spres/spres b/c the units are Pa/gridcell --> hPa/[100 km]^2 +vunits = ['percent'] + ['hPa','hPa','hPa/[100 km]^2','hPa/[1000 km]','km'] + +'''******************************************* +Main Analysis +*******************************************''' +print("Main Analysis") +# Ensure that folders exist to store outputs +try: + os.chdir(outpath+"/Aggregation"+typ+"/"+str(kSizekm)+"km") +except: + os.mkdir(outpath+"/Aggregation"+typ+"/"+str(kSizekm)+"km") + os.chdir(outpath+"/Aggregation"+typ+"/"+str(kSizekm)+"km") +priorfiles = os.listdir() + +print("Step 1. Load Files and References") + +proj = nc.Dataset(suppath+"/EASE2_N0_"+str(spres)+"km_Projection.nc") +lats = proj['lat'][:] + +kSize = int(kSizekm/spres) # This needs to be the full width ('diameter'), not the half width ('radius') for ndimage filters + +print("Step 2. Aggregation requested for " + str(starttime[0]) + "-" + str(endtime[0]-1)) +startyears, endyears = [starttime[0] for i in vNames], [endtime[0] for i in vNames] +firstyears, nextyears = [starttime[0] for i in vNames], [endtime[0] for i in vNames] # Set to the same by default +for v in varsi: + name = ver+"_AggregationFields_Monthly_"+vNames[v]+".nc" + if name in priorfiles: + prior = nc.Dataset(name) + + nextyears[v] = int(np.ceil(prior['time'][:].max())) + firstyears[v] = int(np.floor(prior['time'][:].min())) + + if starttime[0] < firstyears[v]: # If the desired time range starts before the prior years... + if endtime[0] >= firstyears[v]: + startyears[v], endyears[v] = starttime[0], firstyears[v] + else: + raise Exception("There is a gap between the ending year requested ("+str(endtime[0]-1)+") and the first year already aggregated ("+str(firstyears[v])+"). Either increase the ending year or choose a different destination folder.") + elif endtime[0] > nextyears[v]: # If the desired range ends after the prior years... + if starttime[0] <= nextyears[v]: + startyears[v], endyears[v] = nextyears[v], endtime[0] + else: + raise Exception("There is a gap between the last year already aggregated ("+str(nextyears[v]-1)+") and the starting year requested ("+str(starttime[0])+"). Either decrease the starting year or choose a different destination folder.") + else: + raise Exception("All requested years are already aggregated.") + else: + startyears[0], endyears[0] = starttime[0], endtime[0] + +# Start at the earliest necessary time for ALL variables of interest +newstarttime = [np.min(np.array(startyears)[varsi]),1,1,0,0,0] +newendtime = [np.max(np.array(endyears)[varsi]),1,1,0,0,0] + +print("Some years may have already been aggregated.\nAggregating for " + str(newstarttime[0]) + "-" + str(newendtime[0]-1) + ".") + +vlists = [ [] for v in vNames] + +mt = newstarttime +while mt != newendtime: + # Extract date + Y = str(mt[0]) + MM = months[mt[1]-1] + M = mons[mt[1]-1] + print(" " + Y + " - " + MM) + + mtdays = md.daysBetweenDates(dateref,mt,lys=1) # Convert date to days since [1900,1,1,0,0,0] + mt0 = md.timeAdd(mt,[-i for i in monthstep],lys=1) # Identify time for the previous month + + # Define number of valid times for making %s from counting stats + if MM == "Feb" and md.leapyearBoolean(mt)[0] == 1: + n = 29*(24/timestep[3]) + else: + n = dpm[mt[1]-1]*(24/timestep[3]) + + ### LOAD TRACKS ### + # Load Cyclone/System Tracks + cs = pd.read_pickle(inpath+"/"+bboxnum+"/"+typ+"Tracks/"+Y+"/"+bboxnum+typ.lower()+"tracks"+Y+M+".pkl") + # cs = pickle5.load(open(inpath+"/"+bboxnum+"/"+typ+"Tracks/"+Y+"/"+bboxnum+typ.lower()+"tracks"+Y+M+".pkl",'rb')) + + ### LIMIT TRACKS & IDS ### + # Limit to tracks that satisfy minimum lifespan and track length + trs = [c for c in cs if ((c.lifespan() > minls) and (c.trackLength() >= mintl))] + + ### CALCULATE FIELDS ### + # Create empty fields + fields = [np.zeros(lats.shape) for i in range(len(vNames))] + + for tr in trs: + trdata = tr.data[np.isfinite(np.array(tr.data.p_cent))][:-1] + + for i in trdata.index: + x = int(trdata.x[i]) + y = int(trdata.y[i]) + + fields[0][y,x] += 1 # Add one to the count + for vi in varsi[1:]: # Add table value for intensity measures + fields[vi][y,x] += float(trdata[vNames[vi]][i]) + + # Append to main list + field0sm = np.array( ndimage.generic_filter( fields[0], np.nansum, kSize, mode='nearest' ) ) + vlists[0].append( field0sm/n*100 ) # convert count to a % + for vi in varsi[1:]: + fieldsm = np.array( ndimage.generic_filter( fields[vi], np.nansum, kSize, mode='nearest' ) ) / field0sm + vlists[vi].append(fieldsm*multiplier[vi]) # append to list + + # Increment Month + mt = md.timeAdd(mt,monthstep,lys=1) + +### SAVE FILE ### +print("Step 3. Write to NetCDF") +for v in varsi: + print(vNames[v]) + mnc = nc.Dataset(ver+"_AggregationFields_Monthly_"+vNames[v]+"_NEW.nc",'w') + mnc.createDimension('y', lats.shape[0]) + mnc.createDimension('x', lats.shape[1]) + mnc.createDimension('time', (max(nextyears[v],newendtime[0])-min(firstyears[v],newstarttime[0]))*12) + mnc.description = 'Aggregation of cyclone track ' + vNames[v] + ' on monthly time scale.' + + ncy = mnc.createVariable('y', np.float32, ('y',)) + ncx = mnc.createVariable('x', np.float32, ('x',)) + ncy.units, ncx.units = 'm', 'm' + ncy[:] = np.arange(proj['lat'].shape[0]*spres*1000/-2 + (spres*1000/2),proj['lat'].shape[0]*spres*1000/2, spres*1000) + ncx[:] = np.arange(proj['lat'].shape[1]*spres*1000/-2 + (spres*1000/2),proj['lat'].shape[1]*spres*1000/2, spres*1000) + + # Add times, lats, and lons + nctime = mnc.createVariable('time', np.float32, ('time',)) + nctime.units = 'years' + nctime[:] = np.arange(min(firstyears[v],newstarttime[0]),max(nextyears[v],newendtime[0]),1/12) + + nclon = mnc.createVariable('lon', np.float32, ('y','x')) + nclon.units = 'degrees' + nclon[:] = proj['lon'][:] + + nclat = mnc.createVariable('lat', np.float32, ('y','x')) + nclat.units = 'degrees' + nclat[:] = proj['lat'][:] + + vout = np.array(vlists[v]) + vout = np.where(vout == 0,np.nan,vout) + ncvar = mnc.createVariable(vNames[v], np.float64, ('time','y','x')) + ncvar.units = vunits[v] + ' -- Smoothing:' + str(kSizekm) + ' km' + + name = ver+"_AggregationFields_Monthly_"+vNames[v]+".nc" + if name in priorfiles: # Append data if prior data existed... + if vout.shape[0] > 0: # ...and there is new data to be added + prior = nc.Dataset(name) + + if (newstarttime[0] <= firstyears[v]) and (newendtime[0] >= nextyears[v]): # If the new data starts before and ends after prior data + ncvar[:] = vout + + elif (newstarttime[0] > firstyears[v]) and (newendtime[0] < nextyears[v]): # If the new data starts after and ends before prior data + ncvar[:] = np.concatenate( ( prior[vNames[v]][prior['time'][:].data < newstarttime[0],:,:].data , vout , prior[vNames[v]][prior['time'][:].data >= newendtime[0],:,:].data ) ) + + elif (newendtime[0] <= firstyears[v]): # If the new data starts and ends before the prior data + ncvar[:] = np.concatenate( ( vout , prior[vNames[v]][prior['time'][:].data >= newendtime[0],:,:].data ) ) + + elif (newstarttime[0] >= nextyears[v]): # If the new data starts and ends after the prior data + ncvar[:] = np.concatenate( ( prior[vNames[v]][prior['time'][:].data < newstarttime[0],:,:].data , vout ) ) + + else: + mnc.close() + raise Exception("Times are misaligned. Requested Time Range: " + str(starttime) + "-" + str(endtime) + ". Processed Time Range: " + str(newstarttime) + "-" + str(newendtime) + ".") + + prior.close(), mnc.close() + os.remove(name) # Remove old file + os.rename(ver+"_AggregationFields_Monthly_"+vNames[v]+"_NEW.nc", name) # rename new file to standard name + + else: # Create new data if no prior data existed + ncvar[:] = vout + mnc.close() + os.rename(ver+"_AggregationFields_Monthly_"+vNames[v]+"_NEW.nc", name) # rename new file to standard name + +if (newendtime[0] < endtime[0]) & (max(nextyears) < endtime[0]): + print("Completed aggregating " + str(newstarttime[0]) + "-" + str(newendtime[0]-1)+".\nRe-run this script to aggregate any time after " + str(max(nextyears[v],newendtime[0])-1) + ".") +else: + print("Completed aggregating " + str(newstarttime[0]) + "-" + str(newendtime[0]-1)+".") diff --git a/Version 13_2 Scripts/C6A_Track_Aggregation_Append_v13_rates.py b/Version 13_2 Scripts/C6A_Track_Aggregation_Append_v13_rates.py new file mode 100644 index 0000000..cfb4b9e --- /dev/null +++ b/Version 13_2 Scripts/C6A_Track_Aggregation_Append_v13_rates.py @@ -0,0 +1,266 @@ +''' +Author: Alex Crawford +Date Created: 18 Feb 2021 +Date Modified: 13 Sep 2021: If a pre-existing file exists, this script will append new results + instead of overwriting for all years. Climatologies no longer in this script. + 01 Nov 2021: Added the possibility of appending prior years as will as subsequent years. + 23 Jan 2023: Adapted to version 13 + +User inputs: + Path Variables, including the reanalysis (ERA, MERRA, CFSR) + Track Type (typ): Cyclone or System + Bounding Box ID (bboxnum): 2-digit character string + Time Variables: when to start, end, the time step of the data + +''' + +'''******************** +Import Modules +********************''' +# Import clock: +from time import perf_counter as clock +start = clock() +import warnings +warnings.filterwarnings("ignore") + +print("Loading modules.") +import os +import pandas as pd +from scipy import ndimage +import numpy as np +import netCDF4 as nc +# import pickle5 +import CycloneModule_13_2 as md + +'''******************************************* +Set up Environment +*******************************************''' +print("Setting up environment.") +bboxnum = "BBox27" # use "" if performing on all cyclones; or BBox## +typ = "System" +ver = "13_2R" + +path = "/Volumes/Cressida" +inpath = path+"/CycloneTracking/tracking"+ver +outpath = inpath+"/"+bboxnum +suppath = path+"/Projections" + +'''******************************************* +Define Variables +*******************************************''' +print("Defining variables") +# Time Variables +starttime = [1950,1,1,0,0,0] # Format: [Y,M,D,H,M,S] +endtime = [1951,1,1,0,0,0] # stop BEFORE this time (exclusive) +monthstep = [0,1,0,0,0,0] # A Time step that increases by 1 month [Y,M,D,H,M,S] + +dateref = [1900,1,1,0,0,0] # [Y,M,D,H,M,S] + +seasons = np.array([1,2,3,4,5,6,7,8,9,10,11,12]) # Ending month for three-month seasons (e.g., 2 = DJF) +months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] +mons = ["01","02","03","04","05","06","07","08","09","10","11","12"] +dpm = [31,28,31,30,31,30,31,31,30,31,30,31] + +# Aggregation Parameters +minls = 1 # minimum lifespan (in days) for a track to be considered +mintl = 1000 # minimum track length (in km for version ≥ 11.1; grid cells for version ≤ 10.10) for a track to be considered +kSizekm = 800 # Full kernel size (in km) for spatial averaging measured between grid cell centers. + ## For a 100 km spatial resolution, 400 is a 4 by 4 kernel; i.e., kSize = (kSizekm/spres) + +# Variables (Note that countU is mandatory) +varsi = [0] + [1,2,3] + [4] + [5,6] # +vNames = ["countU"] + ["DpDt","u","v"] + ['uv'] + ['vratio','mci'] +multiplier = [1] + [0.01,1,1] + [1,1,1] + [1,1] +vunits = ['percent'] + ['hPa/day','km/h','km/h'] + ['km/h'] + ['ratio of |v| to |uv|', 'ratio of v^2 to (v^2 + u^2)'] + +'''******************************************* +Main Analysis +*******************************************''' +# Ensure that folders exist to store outputs +try: + os.chdir(outpath+"/Aggregation"+typ+"/"+str(kSizekm)+"km") +except: + os.mkdir(outpath+"/Aggregation"+typ+"/"+str(kSizekm)+"km") + os.chdir(outpath+"/Aggregation"+typ+"/"+str(kSizekm)+"km") +priorfiles = os.listdir() + +print("Step 1. Load Files and References") +# Read in attributes of reference files +params = pd.read_pickle(inpath+"/cycloneparams.pkl") +# params = pickle5.load(open(inpath+"/cycloneparams.pkl",'rb')) +timestep = params['timestep'] +try: + spres = params['spres'] +except: + spres = 100 + +proj = nc.Dataset(suppath+"/EASE2_N0_"+str(spres)+"km_Projection.nc") +lats = proj['lat'][:] + +kSize = int(kSizekm/spres) # This needs to be the full width ('diameter'), not the half width ('radius') for ndimage filters + +print("Step 2. Aggregation requested for " + str(starttime[0]) + "-" + str(endtime[0]-1)) +startyears, endyears = [starttime[0] for i in vNames], [endtime[0] for i in vNames] +firstyears, nextyears = [starttime[0] for i in vNames], [endtime[0] for i in vNames] +for v in varsi: + name = ver+"_AggregationFields_Monthly_"+vNames[v]+".nc" + if name in priorfiles: + prior = nc.Dataset(name) + + nextyears[v] = int(np.ceil(prior['time'][:].max())) + firstyears[v] = int(np.floor(prior['time'][:].min())) + + if starttime[0] < firstyears[v]: # If the desired time range starts before the prior years... + if endtime[0] >= firstyears[v]: + startyears[v], endyears[v] = starttime[0], firstyears[v] + else: + raise Exception("There is a gap between the ending year requested ("+str(endtime[0]-1)+") and the first year already aggregated ("+str(firstyears[v])+"). Either increase the ending year or choose a different destination folder.") + elif endtime[0] > nextyears[v]: # If the desired range ends after the prior years... + if starttime[0] <= nextyears[v]: + startyears[v], endyears[v] = nextyears[v], endtime[0] + else: + raise Exception("There is a gap between the last year already aggregated ("+str(nextyears[v]-1)+") and the starting year requested ("+str(starttime[0])+"). Either decrease the starting year or choose a different destination folder.") + else: + raise Exception("All requested years are already aggregated.") + else: + startyears[0], endyears[0] = starttime[0], endtime[0] + +# Start at the earliest necessary time for ALL variables of interest +newstarttime = [np.min(np.array(startyears)[varsi]),1,1,0,0,0] +newendtime = [np.max(np.array(endyears)[varsi]),1,1,0,0,0] + +print("Some years may have already been aggregated.\nAggregating for " + str(newstarttime[0]) + "-" + str(newendtime[0]-1) + ".") + +vlists = [ [] for v in vNames] + +mt = newstarttime +while mt != newendtime: + # Extract date + Y = str(mt[0]) + MM = months[mt[1]-1] + M = mons[mt[1]-1] + print(" " + Y + " - " + MM) + + mtdays = md.daysBetweenDates(dateref,mt,lys=1) # Convert date to days since [1900,1,1,0,0,0] + mt0 = md.timeAdd(mt,[-i for i in monthstep],lys=1) # Identify time for the previous month + + # Define number of valid times for making %s from counting stats + if MM == "Feb" and md.leapyearBoolean(mt)[0] == 1: + n = 29*(24/timestep[3]) + else: + n = dpm[mt[1]-1]*(24/timestep[3]) + + ### LOAD TRACKS ### + # Load Cyclone/System Tracks + # cs = pickle5.load(open(inpath+"/"+bboxnum+"/"+typ+"Tracks/"+Y+"/"+bboxnum+typ.lower()+"tracks"+Y+M+".pkl",'rb')) + cs = pd.read_pickle(inpath+"/"+bboxnum+"/"+typ+"Tracks/"+Y+"/"+bboxnum+typ.lower()+"tracks"+Y+M+".pkl") + + ### LIMIT TRACKS & IDS ### + # Limit to tracks that satisfy minimum lifespan and track length + trs = [c for c in cs if ((c.lifespan() > minls) and (c.trackLength() >= mintl))] + + ### CALCULATE FIELDS ### + # Create empty fields + fields = [np.zeros(lats.shape) for i in range(len(vNames))] + + for tr in trs: + uvab = np.array(tr.data['uv']) + + # V Ratio & MCI + vratio = np.zeros_like(uvab)*np.nan + vratio[uvab > 0] = np.abs( np.array(tr.data['v'])[uvab > 0] / uvab[uvab > 0] ) + tr.data['vratio'] = vratio + + mci = np.zeros_like(uvab)*np.nan + mci[uvab > 0] = np.square( np.array(tr.data['v'])[uvab > 0] / uvab[uvab > 0] ) + tr.data['mci'] = mci + + # Subset + trdata = tr.data[np.isfinite(list(tr.data.u))][:-1] + + for i in trdata.index: + x = int(trdata.x[i]) + y = int(trdata.y[i]) + + fields[0][y,x] += 1 # Add one to the count + for vi in varsi[1:]: # Add table value for intensity measures + fields[vi][y,x] += float(trdata[vNames[vi]][i]) + + # Append to main list + field0sm = np.array( ndimage.generic_filter( fields[0], np.nansum, kSize, mode='nearest' ) ) + vlists[0].append( field0sm/n*100 ) # convert count to a % + for vi in varsi[1:]: + fieldsm = np.array( ndimage.generic_filter( fields[vi], np.nansum, kSize, mode='nearest' ) ) / field0sm + vlists[vi].append(fieldsm*multiplier[vi]) # append to list + + # Increment Month + mt = md.timeAdd(mt,monthstep,lys=1) + +### SAVE FILE ### +print("Step 3. Write to NetCDF") +for v in varsi: + print(vNames[v]) + mnc = nc.Dataset(ver+"_AggregationFields_Monthly_"+vNames[v]+"_NEW.nc",'w') + mnc.createDimension('y', lats.shape[0]) + mnc.createDimension('x', lats.shape[1]) + mnc.createDimension('time', (max(nextyears[v],newendtime[0])-min(firstyears[v],newstarttime[0]))*12) + mnc.description = 'Aggregation of cyclone track ' + vNames[v] + ' on monthly time scale.' + + ncy = mnc.createVariable('y', np.float32, ('y',)) + ncx = mnc.createVariable('x', np.float32, ('x',)) + ncy.units, ncx.units = 'm', 'm' + ncy[:] = np.arange(proj['lat'].shape[0]*spres*1000/-2 + (spres*1000/2),proj['lat'].shape[0]*spres*1000/2, spres*1000) + ncx[:] = np.arange(proj['lat'].shape[1]*spres*1000/-2 + (spres*1000/2),proj['lat'].shape[1]*spres*1000/2, spres*1000) + + # Add times, lats, and lons + nctime = mnc.createVariable('time', np.float32, ('time',)) + nctime.units = 'years' + nctime[:] = np.arange(min(firstyears[v],newstarttime[0]),max(nextyears[v],newendtime[0]),1/12) + + nclon = mnc.createVariable('lon', np.float32, ('y','x')) + nclon.units = 'degrees' + nclon[:] = proj['lon'][:] + + nclat = mnc.createVariable('lat', np.float32, ('y','x')) + nclat.units = 'degrees' + nclat[:] = proj['lat'][:] + + vout = np.array(vlists[v]) + vout = np.where(vout == 0,np.nan,vout) + ncvar = mnc.createVariable(vNames[v], np.float64, ('time','y','x')) + ncvar.units = vunits[v] + ' -- Smoothing:' + str(kSizekm) + ' km' + + name = ver+"_AggregationFields_Monthly_"+vNames[v]+".nc" + if name in priorfiles: # Append data if prior data existed... + if vout.shape[0] > 0: # ...and there is new data to be added + prior = nc.Dataset(name) + + if (newstarttime[0] <= firstyears[v]) and (newendtime[0] >= nextyears[v]): # If the new data starts before and ends after prior data + ncvar[:] = vout + + elif (newstarttime[0] > firstyears[v]) and (newendtime[0] < nextyears[v]): # If the new data starts after and ends before prior data + ncvar[:] = np.concatenate( ( prior[vNames[v]][prior['time'][:].data < newstarttime[0],:,:].data , vout , prior[vNames[v]][prior['time'][:].data >= newendtime[0],:,:].data ) ) + + elif (newendtime[0] <= firstyears[v]): # If the new data starts and ends before the prior data + ncvar[:] = np.concatenate( ( vout , prior[vNames[v]][prior['time'][:].data >= newendtime[0],:,:].data ) ) + + elif (newstarttime[0] >= nextyears[v]): # If the new data starts and ends after the prior data + ncvar[:] = np.concatenate( ( prior[vNames[v]][prior['time'][:].data < newstarttime[0],:,:].data , vout ) ) + + else: + mnc.close() + raise Exception("Times are misaligned. Requested Time Range: " + str(starttime) + "-" + str(endtime) + ". Processed Time Range: " + str(newstarttime) + "-" + str(newendtime) + ".") + + prior.close(), mnc.close() + os.remove(name) # Remove old file + os.rename(ver+"_AggregationFields_Monthly_"+vNames[v]+"_NEW.nc", name) # rename new file to standard name + + else: # Create new data if no prior data existed + ncvar[:] = vout + mnc.close() + os.rename(ver+"_AggregationFields_Monthly_"+vNames[v]+"_NEW.nc", name) # rename new file to standard name + +if (newendtime[0] < endtime[0]) & (max(nextyears) < endtime[0]): + print("Completed aggregating " + str(newstarttime[0]) + "-" + str(newendtime[0]-1)+".\nRe-run this script to aggregate any time after " + str(max(nextyears[v],newendtime[0])-1) + ".") +else: + print("Completed aggregating " + str(newstarttime[0]) + "-" + str(newendtime[0]-1)+".") diff --git a/Version 13_2 Scripts/C6A_Track_Aggregation_Append_v13_trkden.py b/Version 13_2 Scripts/C6A_Track_Aggregation_Append_v13_trkden.py new file mode 100644 index 0000000..04b00f6 --- /dev/null +++ b/Version 13_2 Scripts/C6A_Track_Aggregation_Append_v13_trkden.py @@ -0,0 +1,247 @@ +''' +Author: Alex Crawford +Date Created: 10 Mar 2015 +Date Modified: 18 Apr 2016; 10 Jul 2019 (update for Python 3); + 10 Sep 2020 (switch from geotiff to netcdf), switch to uniform_filter from scipy.ndimage + 30 Sep 2020 (switch back to slower custom smoother because of what scipy does to NaNs) + 18 Feb 2021 (edited seasonal caluclations to work directly from months, not monthly climatology, + allowing for cross-annual averaging) + 09 Sep 2021: If a pre-existing file exists, this script will append new results + instead of overwriting for all years. Climatologies no longer in this script. + 01 Nov 2021: Added the possibility of appending prior years as will as subsequent years. + 23 Jan 2023: Adapted to version 13 +Purpose: Calculate aggergate track density (Eulerian-Lagrangian hybrid) for either +cyclone tracks or system tracks. + +User inputs: + Path Variables, including the reanalysis (ERA, MERRA, CFSR) + Track Type (typ): Cyclone or System + Bounding Box ID (bboxnum): 2-digit character string + Time Variables: when to start, end, the time step of the data + Aggregation Parameters (minls, mintl, kSizekm) + +Note: Units for track density are tracks/month/gridcell +''' + +'''******************** +Import Modules +********************''' +# Import clock: +from time import perf_counter as clock +start = clock() +import warnings +warnings.filterwarnings("ignore") + +print("Loading modules.") +import os +import pandas as pd +from scipy import ndimage +from scipy import interpolate +import numpy as np +import netCDF4 as nc +# import pickle5 +import CycloneModule_13_2 as md + +'''******************************************* +Set up Environment +*******************************************''' +print("Setting up environment.") +bboxnum = "BBox27" # use "" if performing on all cyclones; or BBox## +typ = "System" +ver = "13_2R" + +path = "/Volumes/Cressida" +inpath = path+"/CycloneTracking/tracking"+ver +outpath = inpath+"/"+bboxnum +suppath = path+"/Projections" + +'''******************************************* +Define Variables +*******************************************''' +print("Defining variables") +# Time Variables +starttime = [1950,1,1,0,0,0] # Format: [Y,M,D,H,M,S] +endtime = [1951,1,1,0,0,0] # stop BEFORE this time (exclusive) +monthstep = [0,1,0,0,0,0] # A Time step that increases by 1 month [Y,M,D,H,M,S] + +seasons = np.array([1,2,3,4,5,6,7,8,9,10,11,12]) # Ending month for three-month seasons (e.g., 2 = DJF) +months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] +mons = ["01","02","03","04","05","06","07","08","09","10","11","12"] +dpm = [31,28,31,30,31,30,31,31,30,31,30,31] + +# Aggregation Parameters +minls = 1 # minimum lifespan (in days) for a track to be considered +mintl = 1000 # minimum track length (in km for version ≥ 11.1; grid cells for version ≤ 10.10) for a track to be considered +kSizekm = 400 # Full kernel size (in km) for spatial averaging measured between grid cell centers. + ## For a 100 km spatial resolution, 400 is a 4 by 4 kernel; i.e., kSize = (kSizekm/spres) + +# Variables +vName = "trkden" +vunits = 'count' + +'''******************************************* +Main Analysis +*******************************************''' +print("Main Analysis") +# Ensure that folders exist to store outputs +try: + os.chdir(outpath+"/Aggregation"+typ) +except: + os.mkdir(outpath+"/Aggregation"+typ) + os.chdir(outpath+"/Aggregation"+typ) +try: + os.chdir(outpath+"/Aggregation"+typ+"/"+str(kSizekm)+"km") +except: + os.mkdir(outpath+"/Aggregation"+typ+"/"+str(kSizekm)+"km") + os.chdir(outpath+"/Aggregation"+typ+"/"+str(kSizekm)+"km") +priorfiles = os.listdir() + +print("Step 1. Load Files and References") +# Read in attributes of reference files +params = pd.read_pickle(inpath+"/cycloneparams.pkl") +# params = pickle5.load(open(inpath+"/cycloneparams.pkl",'rb')) +timestep = params['timestep'] +try: + spres = params['spres'] +except: + spres = 100 + +proj = nc.Dataset(suppath+"/EASE2_N0_"+str(spres)+"km_Projection.nc") +lats = proj['lat'][:] + +kSize = int(kSizekm/spres) # This needs to be the full width ('diameter'), not the half width ('radius') for ndimage filters + +print("Step 2. Aggregation requested for " + str(starttime[0]) + "-" + str(endtime[0]-1)) +name = ver+"_AggregationFields_Monthly_"+vName+".nc" +if name in priorfiles: + prior = nc.Dataset(name) + nextyear = int(np.ceil(prior['time'][:].max())) + firstyear = int(np.floor(prior['time'][:].min())) + if starttime[0] < firstyear: # If the desired time range starts before the prior years... + if endtime[0] >= firstyear: + startyear, endyear = starttime[0], firstyear + print("Years " + str(firstyear) + "-"+str(nextyear-1) + " were already aggregated.\nAggregating for " + str(startyear) + "-" + str(endyear-1) + ".") + else: + raise Exception("There is a gap between the ending year requested ("+str(endtime[0]-1)+") and the first year already aggregated ("+str(firstyear)+"). Either increase the ending year or choose a different destination folder.") + elif endtime[0] > nextyear: # If the desired range ends after the prior years... + if starttime[0] <= nextyear: + startyear, endyear = nextyear, endtime[0] + print("Years " + str(firstyear) + "-"+str(nextyear-1) + " were already aggregated.\nAggregating for " + str(startyear) + "-" + str(endyear-1) + ".") + else: + raise Exception("There is a gap between the last year already aggregated ("+str(nextyear-1)+") and the starting year requested ("+str(starttime[0])+"). Either decrease the starting year or choose a different destination folder.") + else: + raise Exception("All requested years are already aggregated.") +else: + startyear, endyear, firstyear, nextyear = starttime[0], endtime[0], starttime[0], endtime[0] + +# Start at the earliest necessary time for ALL variables of interest +newstarttime = [startyear,1,1,0,0,0] +newendtime = [endyear,1,1,0,0,0] + +vlists = [] + +mt = newstarttime +while mt != newendtime: + # Extract date + Y = str(mt[0]) + MM = months[mt[1]-1] + M = mons[mt[1]-1] + if MM == "Jan": + print(" " + Y) + + ### LOAD TRACKS ### + # Load Cyclone/System Tracks + # cs = pickle5.load(open(inpath+"/"+bboxnum+"/"+typ+"Tracks/"+Y+"/"+bboxnum+typ.lower()+"tracks"+Y+M+".pkl",'rb')) + cs = pd.read_pickle(inpath+"/"+bboxnum+"/"+typ+"Tracks/"+Y+"/"+bboxnum+typ.lower()+"tracks"+Y+M+".pkl") + + ### LIMIT TRACKS & IDS ### + # Limit to tracks that satisfy minimum lifespan and track length + trs = [c for c in cs if ((c.lifespan() > minls) and (c.trackLength() >= mintl))] + + ### CALCULATE FIELDS ### + trk_field = np.zeros_like(lats) + for tr in trs: + # Extract time and location + xs = np.array(tr.data.x) + ys = np.array(tr.data.y) + hours = np.array(tr.data.time*24) + + # Interpolate to hourly + f = interpolate.interp1d(hours,xs) + xs2 = f(np.arange(hours[0],hours[-1])).astype(int) + f = interpolate.interp1d(hours,ys) + ys2 = f(np.arange(hours[0],hours[-1])).astype(int) + + # Zip together ys and xs and find unique values + yxs2 = np.transpose(np.vstack( (ys2,xs2) )) + yxs3 = np.unique(yxs2,axis=0) + + # Record Existance of Track at each unique point + for i in range(yxs3.shape[0]): + x = yxs3[i,1] + y = yxs3[i,0] + + trk_field[y,x] += 1 + + ### SMOOTH FIELDS ### + varFieldsm = ndimage.uniform_filter(trk_field,kSize,mode="nearest") # --> This cannot handle NaNs + vlists.append(varFieldsm) # append to list + + # Increment Month + mt = md.timeAdd(mt,monthstep,lys=1) + +### SAVE FILE ### +print("Step 3. Write to NetCDF") +mnc = nc.Dataset(ver+"_AggregationFields_Monthly_"+vName+"_NEW.nc",'w') +mnc.createDimension('y', lats.shape[0]) +mnc.createDimension('x', lats.shape[1]) +mnc.createDimension('time', (max(nextyear,newendtime[0])-min(firstyear,newstarttime[0]))*12) +mnc.description = 'Aggregation of cyclone track characteristics on monthly time scale.' + +ncy = mnc.createVariable('y', np.float32, ('y',)) +ncx = mnc.createVariable('x', np.float32, ('x',)) +ncy.units, ncx.units = 'm', 'm' +ncy[:] = np.arange(proj['lat'].shape[0]*spres*1000/-2 + (spres*1000/2),proj['lat'].shape[0]*spres*1000/2, spres*1000) +ncx[:] = np.arange(proj['lat'].shape[1]*spres*1000/-2 + (spres*1000/2),proj['lat'].shape[1]*spres*1000/2, spres*1000) + +# Add times, lats, and lons +nctime = mnc.createVariable('time', np.float32, ('time',)) +nctime.units = 'years' +nctime[:] = np.arange(min(firstyear,newstarttime[0]),max(nextyear,newendtime[0]),1/12) + +nclon = mnc.createVariable('lon', np.float32, ('y','x')) +nclon.units = 'degrees' +nclon[:] = proj['lon'][:] + +nclat = mnc.createVariable('lat', np.float32, ('y','x')) +nclat.units = 'degrees' +nclat[:] = proj['lat'][:] + +ncvar = mnc.createVariable(vName, np.float64, ('time','y','x')) +ncvar.units = vunits + ' -- Smoothing:' + str(kSizekm) + ' km' +vout = np.array(vlists) + +name = ver+"_AggregationFields_Monthly_"+vName+".nc" +if name in priorfiles: # Append data if prior data existed... + if vout.shape[0] > 0: # ...and there is new data to be added + prior = nc.Dataset(name) + + if starttime[0] < firstyear: + ncvar[:] = np.concatenate( ( np.where(vout == 0,np.nan,vout) , prior[vName][:].data ) ) + else: + ncvar[:] = np.concatenate( ( prior[vName][:].data , np.where(vout == 0,np.nan,vout) ) ) + + mnc.close() + + os.remove(name) # Remove old file + os.rename(ver+"_AggregationFields_Monthly_"+vName+"_NEW.nc", name) # rename new file to standard name + +else: # Create new data if no prior data existed + ncvar[:] = np.where(vout == 0,np.nan,vout) + mnc.close() + os.rename(ver+"_AggregationFields_Monthly_"+vName+"_NEW.nc", name) # rename new file to standard name + +if (nextyear < endtime[0]) & (firstyear > starttime[0]): + print("Completed aggregating " + str(startyear) + "-" + str(endyear-1)+".\nRe-run this script to aggregate " + str(nextyear) + "-" + str(endtime[0]-1) + ".") +else: + print("Completed aggregating " + str(startyear) + "-" + str(endyear-1)+".") diff --git a/Version 13_2 Scripts/C6B_Track_Aggregation_Climatology_v13.py b/Version 13_2 Scripts/C6B_Track_Aggregation_Climatology_v13.py new file mode 100644 index 0000000..b724cbf --- /dev/null +++ b/Version 13_2 Scripts/C6B_Track_Aggregation_Climatology_v13.py @@ -0,0 +1,224 @@ +''' +Author: Alex Crawford +Date Created: 13 Sep 2021 +Date Modified: 23 Jan 2023 +Purpose: Calculate Climatologies From Aggregated Cyclone Characteristics +''' + +'''******************** +Import Modules +********************''' +import warnings +warnings.filterwarnings("ignore") + +print("Loading modules.") +import os +import pandas as pd +import numpy as np +import netCDF4 as nc +# import pickle5 +import CycloneModule_13_2 as md + +'''******************************************* +Set up Environment +*******************************************''' +print("Setting up environment.") +bboxnum = "BBox27" # use "" if performing on all cyclones; or BBox## +typ = "System" +ver = "13_2" +kSizekm = 800 +subset2 = '' # '_DeepeningDsqP' + '' + +path = "/Volumes/Cressida" +inpath = path+"/CycloneTracking/tracking"+ver +outpath = inpath+"/"+bboxnum +suppath = path+"/Projections" + +'''******************************************* +Define Variables +*******************************************''' +print("Defining variables") + +ymin, ymax = 1980, 2019 # years for climatology + +dateref = [1900,1,1,0,0,0] # [Y,M,D,H,M,S] +seasons = np.array([1,2,3,4,5,6,7,8,9,10,11,12]) # Ending month for three-month seasons (e.g., 2 = DJF) + +# Variables (note that counts are mandatory if using them as the weight for non-count variables) +varsi = [1,-1] # [0,2,3,4] #[7,9,10,11,15,16,17,18,19] # [10,11] # +# varsi = [0,1] + [2,3,4] + [5,6,7] + [8,9] + \ +# [10,11,12,13] + [14] + [15,16,17,18,19] +vNames = ["countU","countP"] + ["DpDt","u","v"] + ['uab','vab','uvab'] + ['vratio','mci'] + \ + ["gen","lys","spl","mrg"] + ['trkden'] + ["p_cent","depth","DsqP","p_grad","radius"] +vunits = ['percent','percent'] + ['hPa/day','km/h','km/h'] + ['km/h','km/h','km/h'] + ['ratio of |v| to |uv|', 'ratio of v^2 to (v^2 + u^2)'] + \ + ['#/month','#/month','#/month','#/month'] + ['#/month'] + ['hPa','hPa','hPa/[100 km]^2','hPa/[1000 km]','km'] +vcount = ['none','none'] + ['countU','countU','countU'] + ['countU','countU','countU'] + ['countU','countU'] + \ + ['none','none','none','none'] +['none'] + ['countP','countP','countP','countP','countP'] + +'''******************************************* +Main Analysis +*******************************************''' +print("Main Analysis") +# Ensure that folders exist to store outputs +try: + os.chdir(outpath+"/Aggregation"+typ+subset2+"/"+str(kSizekm)+"km") +except: + os.mkdir(outpath+"/Aggregation"+typ+subset2+"/"+str(kSizekm)+"km") + os.chdir(outpath+"/Aggregation"+typ+subset2+"/"+str(kSizekm)+"km") + +# Read in attributes of reference files +params = pd.read_pickle(inpath+"/cycloneparams.pkl") +# params = pickle5.load(open(inpath+"/cycloneparams.pkl",'rb')) + +try: + spres = params['spres'] +except: + spres = 100 + +proj = nc.Dataset(suppath+"/EASE2_N0_"+str(spres)+"km_Projection.nc") +size = proj['lat'].shape + +YY2 = str(ymin) + "-" + str(ymax) + +################################# +##### MONTHLY CLIMATOLOGIES ##### +################################# +print("Step 4. Aggregation By Month") +# Write NetCDF File +mname = ver+"_AggregationFields_MonthlyClimatology_"+YY2+".nc" +try: + mnc = nc.Dataset(mname,'r+') +except: + mnc = nc.Dataset(mname,'w',format="NETCDF4") + mnc.createDimension('y', size[0]) + mnc.createDimension('x', size[1]) + mnc.createDimension('time', 12) + mnc.description = 'Climatology ('+YY2+') of aggregation of cyclone track characteristics on monthly time scale.' + + ncy = mnc.createVariable('y', np.float32, ('y',)) + ncx = mnc.createVariable('x', np.float32, ('x',)) + ncy.units, ncx.units = 'm', 'm' + ncy[:] = np.arange(proj['lat'].shape[0]*spres*1000/-2 + (spres*1000/2),proj['lat'].shape[0]*spres*1000/2, spres*1000) + ncx[:] = np.arange(proj['lat'].shape[1]*spres*1000/-2 + (spres*1000/2),proj['lat'].shape[1]*spres*1000/2, spres*1000) + + # Add times, lats, and lons + nctime = mnc.createVariable('time', np.float32, ('time',)) + nctime.units = 'months' + nctime[:] = np.arange(1,12+1,1) + + nclon = mnc.createVariable('lon', np.float32, ('y','x')) + nclon.units = 'degrees' + nclon[:] = proj['lon'][:] + + nclat = mnc.createVariable('lat', np.float32, ('y','x')) + nclat.units = 'degrees' + nclat[:] = proj['lat'][:] + +for v in varsi: + print(" " + vNames[v]) + ncf = nc.Dataset(ver+"_AggregationFields_Monthly_"+vNames[v]+".nc",'r') + times = ncf['time'][:] + + mlist = [] + for m in range(1,12+1): + tsub = np.where( ((times-((m-1)/12))%1 == 0) & (times >= ymin) & (times < ymax+1) )[0] + + # Load Primary Monthly Data + field_M = ncf[vNames[v]][tsub,:,:].data + field_M[np.isnan(field_M)] = 0 + + if vcount[v] == 'none': # If this is count data... + field_MC = field_M.mean(axis=0) + + else: # If this is not count data... + # Load Count Data for Weighting + ncf0 = nc.Dataset(ver+"_AggregationFields_Monthly_"+vcount[v]+".nc",'r') + field0_M = ncf0[vcount[v]][tsub,:,:].data + field0_M[np.isnan(field0_M)] = 0 + + # Calculate the weighted average + field_MC = (field_M*field0_M).sum(axis=0) / field0_M.sum(axis=0) + ncf0.close() + + mlist.append(field_MC) + + try: + ncvar = mnc.createVariable(vNames[v], np.float64, ('time','y','x')) + ncvar.units = ncf[vNames[v]].units + ncvar[:] = np.array(mlist) + except: + mnc[vNames[v]][:] = np.array(mlist) + +mnc.close() +ncf.close() + +##### SEASONAL MEANS ### +print("Step 5. Aggregate By Season") +sname = ver+"_AggregationFields_SeasonalClimatology_"+YY2+".nc" +if sname in os.listdir(): + snc = nc.Dataset(sname,'r+') +else: + snc = nc.Dataset(sname,'w') + snc.createDimension('y', size[0]) + snc.createDimension('x', size[1]) + snc.createDimension('time', len(seasons)) + snc.description = 'Climatology ('+YY2+') of aggregation of cyclone track characteristics on seasonal time scale.' + + ncy = snc.createVariable('y', np.float32, ('y',)) + ncx = snc.createVariable('x', np.float32, ('x',)) + ncy.units, ncx.units = 'm', 'm' + ncy[:] = np.arange(proj['lat'].shape[0]*spres*1000/-2 + (spres*1000/2),proj['lat'].shape[0]*spres*1000/2, spres*1000) + ncx[:] = np.arange(proj['lat'].shape[1]*spres*1000/-2 + (spres*1000/2),proj['lat'].shape[1]*spres*1000/2, spres*1000) + + # Add times, lats, and lons + nctime = snc.createVariable('time', np.int8, ('time',)) + nctime.units = 'seasonal end months' + nctime[:] = seasons + + nclon = snc.createVariable('lon', np.float32, ('y','x')) + nclon.units = 'degrees' + nclon[:] = proj['lon'][:] + + nclat = snc.createVariable('lat', np.float32, ('y','x')) + nclat.units = 'degrees' + nclat[:] = proj['lat'][:] + +for v in varsi: + ncf = nc.Dataset(ver+"_AggregationFields_Monthly_"+vNames[v]+".nc",'r') + times = ncf['time'][:] + + print(" " + vNames[v]) + + # Load Primary Monthly Data + mlist = [] + for m in seasons: + tsub = np.where( ((times-((m-1)/12))%1 == 0) & (times >= ymin) & (times < ymax+1) )[0] + sitsub = np.concatenate( (tsub, tsub-1, tsub-2) ) + + field_M = ncf[vNames[v]][sitsub,:,:].data + field_M[np.isnan(field_M)] = 0 + + if vcount[v] == 'none': # If this is count data... + field_MC = field_M.mean(axis=0) + + else: # If this is not count data... + # Load Count Data for Weighting + ncf0 = nc.Dataset(ver+"_AggregationFields_Monthly_"+vcount[v]+".nc",'r') + field0_M = ncf0[vcount[v]][sitsub,:,:].data + field0_M[np.isnan(field0_M)] = 0 + + # Calculate the weighted average + field_MC = (field_M*field0_M).sum(axis=0) / field0_M.sum(axis=0) + ncf0.close() + + mlist.append(field_MC) + + try: + ncvar = snc.createVariable(vNames[v], np.float64, ('time','y','x')) + ncvar.units = ncf[vNames[v]].units + ncvar[:] = np.array(mlist) + except: + snc[vNames[v]][:] = np.array(mlist) + +snc.close() +ncf.close() diff --git a/Version 13_2 Scripts/CycloneModule_13_2.py b/Version 13_2 Scripts/CycloneModule_13_2.py new file mode 100644 index 0000000..4263df0 --- /dev/null +++ b/Version 13_2 Scripts/CycloneModule_13_2.py @@ -0,0 +1,3777 @@ +''' +Main Author: Alex Crawford +Contributions from: Mark Serreze, Nathan Sommer +Date Created: 20 Jan 2015 (original module) +Modifications: (since branch from version 11) +21 May 2020 --> Improved speed of kernelgradient function (courtesy Nathan Sommer) & laplacian function +02 Jun 2020 --> Made test for weak minima mandatory during cyclone detection (removing an if statement) + --> Re-ordered the unit conversions in the haversine formula to prioritize m and km +17 Jun 2020 --> Modified how the area mechanism for single-center cyclones works to make + it fewer lines of code but the same number of steps + --> Minor tweak in cTrack2sTrack function so that pandas data.frame row + is pulled out as its own dataframe instead of always being subset +11 Sep 2020 --> Pulled the "maxdist" calculation out of the module and into the top-level + script because it really only needs to be done once + --> Changed method of determining track continuation during merges to + prioritize nearest neighbor, but then maximum depth (instead of lifespan) +02 Oct 2020 --> Changed the findAreas method of cyclonefield objects so that + the fieldCenters is replaced instead of duplicated when distinguishing + primary and secondary centers. Also made area and center fields the uint8 data type. + This greatly reduces the file size of detection outputs. +05 Oct 2020 --> Changed the parent track id assignment during area merges to also + prioritize depth instead of lifespan + --> Added try/except statements to the cTrack2sTrack function so that it will ignore + cases where tids between two months don't align insted of breaking. + +Start Version 12_4 (Branch from 12_2) +13 Jan 2021 --> Removed the columns precip, preciparea, and DpDr from the standard cyclone dataframe output + (saving space) and added a method for cyclone tracks to calculate the maximum distance + from the genesis point that the cyclone is ever observed. +14 Jan 2021 --> Switch in when NaNs are counted, allowing minima over masked areas to interfere with nearby minima + --> Removed an if statement from "findCenters" that I never actually use + (there is always an intensity threshold) +23 Mar 2021 --> Added rotateCoordsAroundOrigin function +15 Apr 2021 --> Modified the findCAP function to work as a post-hoc analysis +28 Apr 2021 --> Add linear regression function +05 May 2021 --> Remove "columns=" statements from initiation of empty pandas dataframes +29 May 2021 --> Fixed bug in linear regression function +05 Aug 2021 --> Added flexibility to circleKernel function so that the masked value can be NaN or 0 +05 Oct 2021 --> Fixed bug in 360-day calendar calculations +15 Oct 2021 --> Replaced the old findCAP2 function with a new one that also tracks the cyclone id for each grid cell containing CAP + --> Removed old functions that are no longer in use +18 Oct 2021 --> Added a NaN-discerning standard deviation function that only calculates SD if there is sufficient number of non-NaN values +26 Oct 2021 --> Added some common constant values +23 Dec 2021 --> Added areaBoundingBox function +25 Feb 2022 --> Edited vectorDirectionFrom function to handle arrays +10 Aug 2022 --> Added listdir function +20 Sep 2022 --> Added a method to cyclone field objects to write them to a CSV file + +Start Version 13_2 (Branch from 12_4) +20 Sep 2022 --> Changed the dist2lon function so that it always returns negative outputs if input is negative + --> Changed u and v variables in standard cyclone dataframe to be velocity instead of speed + --> Removed Dx and Dy from standard cyclone dataframe output + --> Replaced all "longs" with "lons" when referring to longitude + --> Changed sorting of main data frame to no longer always be alphabetical + --> Added the SLP gradient measure used to limit cyclone detection as a variable in the main data frame +18 Oct 2022 --> Added try/except statements to the cTrack2sTrack function so that it will ignore + cases where tids between two months don't align insted of breaking. -- previously this had only been done for same-month cyclones + +''' +__version__ = "13.2" + +import pandas as pd +import copy +import os +import numpy as np +from scipy.spatial.distance import cdist +from scipy import ndimage +from scipy import stats +from scipy import special + +''' +################# +### CONSTANTS ### +################# +''' +mmm = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] +dd = ["01","02","03","04","05","06","07","08","09","10","11","12","13",\ + "14","15","16","17","18","19","20","21","22","23","24","25","26","27",\ + "28","29","30","31"] +hhmm = ["0000","0100","0200","0300","0400","0500","0600","0700","0800",\ + "0900","1000","1100","1200","1300","1400","1500","1600","1700","1800",\ + "1900","2000","2100","2200","2300"] +sss = ['NDJ','DJF','JFM','FMA','MAM','AMJ','MJJ','JJA','JAS','ASO','SON','OND'] +abc = ['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z'] +dpm = [31,28,31,30,31,30,31,31,30,31,30,31] + +''' +############### +### CLASSES ### +############### +''' + +class minimum: + '''This class stores the vital information about a minimum identified in a + single field of data. It is used as a building block for more complicated + objets (cyclone, cyclonefield). Must provide the time, the x and y + locations of the minimum, and the value of the minimum (p). + + The type is used to identify the minimum as 0 = discarded from analysis; + 1 = identified as a system center; 2 = identified as a secondary minimum + within a larger cyclone system. + ''' + def __init__(self,time,y,x,p_cent,i=0,t=0): + self.time = time + self.y = y + self.x = x + self.lat = np.nan + self.lon = np.nan + self.p_cent = p_cent + self.id = i + self.tid = np.nan + self.p_edge = p_cent + self.area = 0 + self.areaID = np.nan + self.DsqP = np.nan + self.p_grad = np.nan + self.type = t + self.secondary = [] # stores the ids of any secondary minima + self.parent = {"y":y,"x":x,"id":i} + self.precip = 0 + self.precipArea = 0 + def radius(self): + return np.sqrt(self.area/(np.pi)) + def depth(self): + return self.p_edge-self.p_cent + def Dp(self): + return self.depth()/self.radius() + def centerCount(self): + if self.type == 1: + return len(self.secondary)+1 + else: + return 0 + def add_parent(self,yPar,xPar,iPar): + self.type = 2 + self.parent = {"y":yPar,"x":xPar,"id":iPar} + +class cyclonefield: + '''This class stores a) binary fields showing presence/absence of cyclone + centers and cyclone areas and b) an object for each cyclone -- all at a + particular instant in time. Good for computing summary values of the + cyclone state at a particular time. By default, the elevations parameters + are set to 0 (ignored in analysis). + + To initiate, must define:\n + time = any format, but [Y,M,D,H,M,S] is suggested + field = a numpy array of SLP (usu. in Pa; floats or ints)\n + + May also include:\n + max_elev = the maximum elevation at which SLP minima will be considered (if + you intend on changing the default, must also load a DEM array) + elevField = an elevation array that must have the same shape as field\n + ''' + ################## + # Initialization # + ################## + + def __init__(self, time): + self.time = time + self.cyclones = [] + + ############################# + # Cyclone Centers Detection # + ############################# + def findMinima(self, field, mask, kSize, nanthreshold=0.5): + '''Identifies minima in the field using limits and kSize. + ''' + self.fieldMinima = detectMinima(field,mask,kSize,nanthreshold).astype(np.int) + def findCenters(self, field, mask, kSize, nanthreshold, d_slp, d_dist, yDist, xDist, lats, lons): + '''Uses the initialization parameters to identify potential cyclones + centers. Identification begins with finding minimum values in the field + and then uses a gradient parameter and elevation parameter to restrict + the number of centers. A few extra characteristics about the minima are + recorded (e.g. lat, lon, Laplacian). Lastly, it adds a minimum object + to the centers list for each center identified in the centers field. + ''' + # STEP 1: Calculate Laplacian + laplac = laplacian(field) + + # STEP 2: Identify Centers + self.fieldCenters, gradients = findCenters(field, mask, kSize, nanthreshold, d_slp, d_dist, yDist, xDist) + + # Identify center locations + rows, cols = np.where(self.fieldCenters > 0) + + # STEP 3: Assign each as a minimum in the centers list: + for c in range(np.sum(self.fieldCenters)): + center = minimum(self.time,rows[c],cols[c],field[rows[c],cols[c]],c,0) + center.lat = lats[rows[c],cols[c]] + center.lon = lons[rows[c],cols[c]] + center.p_grad = gradients[c] + center.DsqP = laplac[rows[c],cols[c]] + self.cyclones.append(center) + + ############################### + # Cyclone Area Identification # + ############################### + def findAreas(self, fieldMask, contint, mcctol, mccdist, lats, lons, kSize): + # Identify maxima + maxes = detectMaxima(fieldMask,fieldMask,kSize) + + # Define Areas, Identify Primary v. Secondary Cyclones + self.fieldAreas, self.fieldCenters, self.cyclones = \ + findAreas(fieldMask, self.fieldCenters, self.cyclones,\ + contint, mcctol, mccdist, lats, lons, maxes) + + # Identify area id for each center + cAreas, nC = ndimage.measurements.label(self.fieldAreas) + for i in range(len(self.cyclones)): + self.cyclones[i].areaID = cAreas[self.cyclones[i].y,self.cyclones[i].x] + + ################ + # Write to CSV # + ################ + def to_csv(self, path, spres=100): + # path is the location at which the file will be saved + # spres is the spatial resolution of the input data -- in km + + # Create PDF + pdf = pd.DataFrame({'id': [cyc.id for cyc in self.cyclones], + 'pid': [cyc.parent['id'] for cyc in self.cyclones], + 'x': [cyc.x for cyc in self.cyclones], + 'y': [cyc.y for cyc in self.cyclones], + 'lat': [cyc.lat for cyc in self.cyclones], + 'lon': [cyc.lon for cyc in self.cyclones], + 'p_cent': [cyc.p_cent for cyc in self.cyclones], + 'depth': [cyc.depth() for cyc in self.cyclones], + 'radius': [cyc.radius() for cyc in self.cyclones], + 'DsqP': [cyc.DsqP for cyc in self.cyclones], + 'p_grad': [cyc.p_grad for cyc in self.cyclones] + }) + + # Convert units + pdf['p_cent'] *= 0.01 # Pa --> hPa + pdf['depth'] *= 0.01 # Pa --> hPa + pdf['radius'] *= spres # gridcells --> km + pdf['DsqP'] *= (100/spres/spres) # Pa / gridcell --> hPa / [100 km]^2 + pdf['p_grad'] *= 10000 # Pa / m --> hPa / [1000 km] + + # Write to file + pdf.to_csv(path,index=False) + + + ###################### + # Summary Statistics # + ###################### + # Summary values: + def cycloneCount(self): + return len([c.type for c in self.cyclones if c.type == 1]) + def area_total(self): + areas = self.area() + counts = self.centerCount() + return sum([areas[a] for a in range(len(areas)) if counts[a] > 0]) + + # Reorganization of cyclone object values: + def x(self): + return [c.x for c in self.cyclones] + def y(self): + return [c.y for c in self.cyclones] + def lats(self): + return [c.lat for c in self.cyclones] + def lons(self): + return [c.lon for c in self.cyclones] + def p_cent(self): + return [c.p_cent for c in self.cyclones] + def p_edge(self): + return [c.p_edge for c in self.cyclones] + def radius(self): + return [c.radius for c in self.cyclones] + def area(self): + return [c.area for c in self.cyclones] + def areaID(self): + return [c.areaID for c in self.cyclones] + def centerType(self): + return [c.type for c in self.cyclones] + def centerCount(self): + return [c.centerCount() for c in self.cyclones] + def tid(self): + return [c.tid for c in self.cyclones] + +class cyclonetrack: + '''This class stores vital information about the track of a single cyclone. + It contains a pandas dataframe built from objets of the class minimum + and provides summary statistics about the cyclone's track and life cycle. + To make sense, the cyclone objects should be entered in chronological order. + + UNITS: + time = days + x, y, dx, dy = grid cells (e.g., 1 = 100 km) + area = sq grid cells (e.g., 1 = 100 km^2) + p_cent, p_edge, depth = Pa + radius = grid cells + u, v, uv = km/hr + DsqP = Pa/(grid cell)^2 + DpDt = Pa/day + id, tid, sid, ftid, otid, centers, type = no units + id = a unique number for each center identified in a SLP field + tid = a unique number for each center track in a given month + ptid = the tid of the parent center in a MCC (ptid == tid is single- + center cyclones) + ftid = the tid of a center in the prior month (only applicable if a + cyclone has genesis in a different month than its lysis) + otid = the tid of a cyclone center that interacts with the given + center (split, merge, re-genesis) + ly, ge, rg, sp, mg = 0: no event occurred, 1: only a center-related event + occurred, 2: only an area-related event occurred, 3: both center- and + area-related events occurred + ''' + ############## + # Initialize # + ############## + def __init__(self,center,tid,Etype=3, ptid=np.nan, ftid=np.nan): + self.tid = tid # A track id + self.ftid = ftid # Former track id + self.ptid = ptid # The most current parent track id + + # Create Main Data Frame + self.data = pd.DataFrame() + row0 = pd.DataFrame([{"time":center.time, "id":center.id, "pid":center.parent["id"],"ptid":ptid,\ + "x":center.x, "y":center.y, "lat":center.lat, "lon":center.lon, \ + "p_cent":center.p_cent, "p_edge":center.p_edge, "area":center.area, \ + "radius":center.radius(), "depth":center.depth(),"p_grad":center.p_grad,\ + "DsqP":center.DsqP,"type":center.type, "centers":center.centerCount(),\ + "Ege":Etype,"Erg":0,"Ely":0,"Esp":0,"Emg":0},]) + self.data = self.data.append(row0, ignore_index=1, sort=0) + + # Create Events Data Frame + self.events = pd.DataFrame() + event0 = pd.DataFrame([{"time":center.time,"id":center.id,"event":"ge",\ + "Etype":Etype,"otid":np.nan,"x":center.x,"y":center.y},]) + self.events = self.events.append(event0, ignore_index=1, sort=1) + + ############### + # Append Data # + ############### + def addInstance(self,center,ptid=-1): + Dt = center.time - self.data.time.iloc[-1] # Identify the time step + + if Dt != 0: + dlon = np.sign(center.lon - self.data.lon.iloc[-1]) # sign for zonal propagation + dlat = np.sign(center.lat - self.data.lat.iloc[-1]) # sign for meridional propagation + u = dlon*haversine(center.lat,center.lat,self.data.lon.iloc[-1],center.lon)/(Dt*1000*24) # zonal propagation + v = dlat*haversine(self.data.lat.iloc[-1],center.lat,center.lon,center.lon)/(Dt*1000*24) # meridional propagation + uv = np.sqrt(np.square(u)+np.square(v)) # propgation speed + + Dp = center.p_cent - self.data.p_cent.iloc[-1] # pressure tendency + DpDt = (Dp/Dt) * (np.sin(np.pi/3)/np.sin(np.pi*center.lat/180)) # Following Roebber (1984) and Serreze et al. (1997), scale the deepening rate by latitude + + if ptid == -1: # If no ptid is given, set it to the track's current ptid. + ptid = self.ptid + + row = pd.DataFrame([{"time":center.time, "id":center.id, "pid":center.parent["id"],\ + "x":center.x, "y":center.y, "lat":center.lat, "lon":center.lon, \ + "p_cent":center.p_cent, "p_edge":center.p_edge, "area":center.area, \ + "radius":center.radius(), "depth":center.depth(),"p_grad":center.p_grad,\ + "DsqP":center.DsqP,"type":center.type, "centers":center.centerCount(),\ + "Dp":Dp, "u":u, "v":v, "uv":uv, "DpDt":DpDt, "ptid":ptid,\ + "Ege":0,"Ely":0,"Esp":0,"Emg":0,"Erg":0},]) + + self.data = self.data.append(row, ignore_index=1, sort=0) + + def removeInstance(self,time): + '''Removes an instance from the main data frame and the events data + frame given a time. Note, this will remove mulitple events if they + occur at the same time. Time is in units of days. + ''' + self.data = self.data.drop(self.data.index[self.data.time == time]) + self.events = self.events.drop(self.events.index[self.events.time == time]) + + def addEvent(self,center,time,event,Etype,otid=np.nan): + '''Events include genesis (ge), regenesis (rg) splitting (sp), merging + (mg), and lysis (ly). Splitting and merging require the id of the + cyclone track being split from or merged with (otid). Note that lysis + is given the time step and location of the last instance of the + cyclone. For all types except rg, the event can be center-based, area- + based, or both. Genesis occurs when a center/area doesn't exist in + time 1 but does exist in time 2. Lysis occurs when a center/area does + exist in time 1 but doesn't in time 2. A split occurs when one center/ + area in time 1 tracks to multiple centers/areas in time 2. A merge + occurs when multiple centers/areas in time 1 track to the same center/ + area in time 2. Regenesis is a special type of area genesis that occurs + if the primary system of multiple centers experiences lysis but the + system continues on from a secondary center. + + The occurrence of events is recorded both in an events data frame and + the main tracking data frame. + + center = an object of class minimum that represents a cyclone center + event = ge, rg, ly, sp, or mg + eType = 1: center only, 2: area only, 3: both center and area + otid = the track id of the other center involved for sp and mg events. + ''' + row = pd.DataFrame([{"time":time,"id":center.id,"event":event,\ + "Etype":Etype,"otid":otid,"x":center.x,"y":center.y},]) + self.events = self.events.append(row, ignore_index=1, sort=1) + + # Event Booleans for Main Data Frame + if event == "ge": + self.data.loc[self.data.time == time,"Ege"] = Etype + elif event == "ly": + self.data.loc[self.data.time == time,"Ely"] = Etype + elif event == "sp": + self.data.loc[self.data.time == time,"Esp"] = Etype + elif event == "mg": + self.data.loc[self.data.time == time,"Emg"] = Etype + elif event == "rg": + self.data.loc[self.data.time == time,"Erg"] = Etype + + ############# + # Summarize # + ############# + def lifespan(self): + '''Subtracts the earliest time stamp from the latest.''' + return np.max(list(self.data.time)) - np.min(list(self.data.loc[self.data.type != 0,"time"])) + def maxDpDt(self): + '''Returns the maximum deepening rate in the track and the + time and location (row, col) in which it occurred.''' + v = np.min(np.where(np.isfinite(list(self.data.DpDt)) == 1,self.data.DpDt,np.inf)) + t = list(self.data.loc[self.data.DpDt == v,"time"]) + y = [int(i) for i in self.data.loc[self.data.DpDt == v,"y"]] + x = [int(i) for i in self.data.loc[self.data.DpDt == v,"x"]] + return v, t, y, x + def maxDsqP(self): + '''Returns the maximum intensity in the track and the time + and location (row, col) in which it occurred.''' + v = np.max(np.where(np.isfinite(list(self.data.DsqP)) == 1,self.data.DsqP,-np.inf)) + t = list(self.data.loc[self.data.DsqP == v,"time"]) + y = [int(i) for i in self.data.loc[self.data.DsqP == v,"y"]] + x = [int(i) for i in self.data.loc[self.data.DsqP == v,"x"]] + return v, t, y, x + def minP(self): + '''Returns the minimum pressure in the track and the + time and location (row, col) in which it occurred.''' + v = np.min(np.where(np.isfinite(list(self.data.p_cent)) == 1,self.data.p_cent,np.inf)) + t = list(self.data.loc[self.data.p_cent == v,"time"]) + y = [int(i) for i in self.data.loc[self.data.p_cent == v,"y"]] + x = [int(i) for i in self.data.loc[self.data.p_cent == v,"x"]] + return v, t, y, x + def maxUV(self): + '''Returns the maximum cyclone propagation speed in the track and the + time and location (row, col) in which it occurred.''' + v = np.max(np.where(np.isfinite(list(self.data.uv)) == 1,self.data.uv,-np.inf)) + t = list(self.data.loc[self.data.uv == v,"time"]) + y = [int(i) for i in self.data.loc[self.data.uv == v,"y"]] + x = [int(i) for i in self.data.loc[self.data.uv == v,"x"]] + return v, t, y, x + def maxDepth(self): + '''Returns the maximum depth in the track and the + time and location (row, col) in which it occurred.''' + v = np.max(np.where(np.isfinite(list(self.data.depth)) == 1,self.data.depth,-np.inf)) + t = list(self.data.loc[self.data.depth == v,"time"]) + y = [int(i) for i in self.data.loc[self.data.depth == v,"y"]] + x = [int(i) for i in self.data.loc[self.data.depth == v,"x"]] + return v, t, y, x + def trackLength(self): + '''Adds together the distance between each segment of the track to find + the total distance traveled (in kms).''' + t = 24*(self.data.time.iloc[1] - self.data.time.iloc[0]) # Hours between timestep + return t*self.data.loc[((self.data.type != 0) | (self.data.Ely > 0)),"uv"].sum() + def maxDistFromGenPnt(self): + '''Returns the maximum distance a cyclone is ever observed from its + genesis point in units of km.''' + v = np.max([haversine(self.data.lat[0],self.data.lat[i],self.data.lon[0],self.data.lon[i]) for i in range(len(self.data.lon))]) + return v/1000 + def avgArea(self): + '''Identifies the average area for the track and the time stamp for + when it occurred.''' + areas = [float(i) for i in self.data.loc[self.data.type != 0,"area"]] + return float(sum(areas))/len(self.data.loc[self.data.type != 0,"area"]) + def mcc(self): + '''Returns a 1 if at any point along the track the cyclone system is + a multi-center cyclone. Retruns a 0 otherwise.''' + if np.nansum([int(c) != 1 for c in self.data.centers.loc[self.data.type != 0]]) == 0: + return 0 + else: + return 1 + def CAP(self): + '''Returns the total cyclone-associated precipitation for the cyclone center.''' + return np.nansum( list(self.data.loc[self.data.type != 0,"precip"]) ) + +class systemtrack: + '''This class stores vital information about the track of a single system. + It contains a pandas dataframe built from objets of the class minimum + and provides summary statistics about the system's track and life cycle. + To make sense, the system track should be constructed directly from + finished cyclone tracks. The difference between a system track and a + cyclone track is that a cyclone track exists for each cyclone center, + whereas only one system track exists for each mcc. + + UNITS: + time = days + x, y, dx, dy = grid cells (1 = 100 km) + area, precipArea = sq grid cells (1 = (100 km)^2) + p_cent, p_edge, depth = Pa + radius = grid cells + u, v, uv = km/hr + DsqP = Pa/(grid cell)^2 + DpDt = Pa/day + id, tid, sid, ftid, otid, centers, type = no units + id = a unique number for each center identified in a SLP field + tid = a unique number for each center track in a given month + sid = a unique number for each system track in a given month + ptid = the tid of the parent center in a MCC (ptid == tid is single- + center cyclones) + ftid = the tid of a center in the prior month (only applicable if a + cyclone has genesis in a different month than its lysis) + otid = the tid of a cyclone center that interacts with the given + center (split, merge, re-genesis) + ly, ge, rg, sp, mg = 0: no event occurred, 1: only a center-related event + occurred, 2: only an area-related event occurred, 3: both center- and + area-related events occurred + ''' + ############## + # Initialize # + ############## + def __init__(self,data,events,tid,sid,ftid=np.nan): + self.tid = tid # A track id + self.ftid = ftid # The former track id + self.sid = sid # A system id + + # Create Main Data Frame + self.data = copy.deepcopy(data) + + # Create Events Data Frame + self.events = copy.deepcopy(events) + + ############# + # Summarize # + ############# + def lifespan(self): + '''Subtracts the earliest time stamp from the latest.''' + return np.max(list(self.data.time)) - np.min(list(self.data.loc[self.data.type != 0,"time"])) + def maxDpDt(self): + '''Returns the maximum deepening rate in the track and the + time and location (row, col) in which it occurred.''' + v = np.min(np.where(np.isfinite(list(self.data.DpDt)) == 1,self.data.DpDt,np.inf)) + t = list(self.data.loc[self.data.DpDt == v,"time"]) + y = [int(i) for i in self.data.loc[self.data.DpDt == v,"y"]] + x = [int(i) for i in self.data.loc[self.data.DpDt == v,"x"]] + return v, t, y, x + def maxDsqP(self): + '''Returns the maximum intensity in the track and the time + and location (row, col) in which it occurred.''' + v = np.max(np.where(np.isfinite(list(self.data.DsqP)) == 1,self.data.DsqP,-np.inf)) + t = list(self.data.loc[self.data.DsqP == v,"time"]) + y = [int(i) for i in self.data.loc[self.data.DsqP == v,"y"]] + x = [int(i) for i in self.data.loc[self.data.DsqP == v,"x"]] + return v, t, y, x + def minP(self): + '''Returns the minimum pressure in the track and the + time and location (row, col) in which it occurred.''' + v = np.min(np.where(np.isfinite(list(self.data.p_cent)) == 1,self.data.p_cent,np.inf)) + t = list(self.data.loc[self.data.p_cent == v,"time"]) + y = [int(i) for i in self.data.loc[self.data.p_cent == v,"y"]] + x = [int(i) for i in self.data.loc[self.data.p_cent == v,"x"]] + return v, t, y, x + def maxUV(self): + '''Returns the maximum cyclone propagation speed in the track and the + time and location (row, col) in which it occurred.''' + v = np.max(np.where(np.isfinite(list(self.data.uv)) == 1,self.data.uv,-np.inf)) + t = list(self.data.loc[self.data.uv == v,"time"]) + y = [int(i) for i in self.data.loc[self.data.uv == v,"y"]] + x = [int(i) for i in self.data.loc[self.data.uv == v,"x"]] + return v, t, y, x + def maxDepth(self): + '''Returns the maximum depth in the track and the + time and location (row, col) in which it occurred.''' + v = np.max(np.where(np.isfinite(list(self.data.depth)) == 1,self.data.depth,-np.inf)) + t = list(self.data.loc[self.data.depth == v,"time"]) + y = [int(i) for i in self.data.loc[self.data.depth == v,"y"]] + x = [int(i) for i in self.data.loc[self.data.depth == v,"x"]] + return v, t, y, x + def trackLength(self): + '''Adds together the distance between each segment of the track to find + the total distance traveled (in kms).''' + t = 24*(self.data.time.iloc[1] - self.data.time.iloc[0]) # Hours between timestep + return t*self.data.loc[((self.data.type != 0) | (self.data.Ely > 0)),"uv"].sum() + def maxDistFromGenPnt(self): + '''Returns the maximum distance a cyclone is ever observed from its + genesis point in units of km.''' + v = np.max([haversine(self.data.lat[0],self.data.lat[i],self.data.lon[0],self.data.lon[i]) for i in range(len(self.data.lon))]) + return v/1000 + def avgArea(self): + '''Identifies the average area for the track and the time stamp for + when it occurred.''' + areas = [float(i) for i in self.data.loc[self.data.type != 0,"area"]] + return float(sum(areas))/len(self.data.loc[self.data.type != 0,"area"]) + def mcc(self): + '''Returns a 1 if at any point along the track the cyclone system is + a multi-center cyclone. Retruns a 0 otherwise.''' + if np.nansum([int(c) != 1 for c in self.data.centers.loc[self.data.type != 0]]) == 0: + return 0 + else: + return 1 + def CAP(self): + '''Returns the total cyclone-associated precipitation for the cyclone center.''' + return np.nansum( list(self.data.loc[self.data.type != 0,"precip"]) ) + +''' +################# +### FUNCTIONS ### +################# +''' + +'''########################### +Moving Average +###########################''' +def movingAverage(arr, n): + '''Given a 1-D numpy array, this function calculates a moving average with a + a spacing of n units. This is a simple smoother with equal weights.\n + + arr = input array (must be a 1-D numpy array)\n + n = size of smoother (integer); identifies the number of instances to be + smoothed --> should always be an odd number. + + Warning: Edges of array remain the original values, so length of output + matches length of input. Must subset if you only want averaged values. + ''' + # Calculate cumulative sum + ret = np.cumsum(arr, dtype=float) + + # Subtract offset + ret[n:] = ret[n:] - ret[:-n] + + # Replace values with moving average + arr2 = arr+0 + arr2[int((n-1)/2):-int((n-1)/2)] = ret[n - 1:] / n + + return arr2 + +'''########################### +Find Nearest Value +###########################''' +def findNearest(array,value): + ''' + Finds the gridcell of a numpy array that most closely matches the given + value. Returns value and its index. + ''' + idx = (np.abs(array-value)).argmin() + return array[idx], idx + +'''########################### +Find Nearest Point +###########################''' +def findNearestPoint(a,B,latlon=0): + ''' + Finds the closest location in the array B to the point a, when a is an + ordered pair (row,col or lat,lon) and B is an array or list of ordered + pairs. All pairs should be in the same format (row,col) or (col,row). + + The optional parameter latlon is 0 by default, meaning that "closest" is + calculated as a Euclidian distance of numpy array positions. If latlon=1, + the haversine formula is used to determine distance instead, which means + all ordered pairs should be (lat,lon). + + Returns the index of the location in B that is closest and the minimum distance + (which is in meters for latlon=1 and matches the input units otherwise). + ''' + if latlon == 0: + dist = [( (b[0]-a[0])**2 + (b[1]-a[1])**2 )**0.5 for b in B] + else: + dist = [haversine(a[0],b[0],a[1],b[1]) for b in B] + i = np.argmin(dist) + + return i , dist[i] + +'''########################### +Find Nearest Area +###########################''' +def findNearestArea(a,B,b="all",latlon=[]): + ''' + Finds the closest unique area in the array B to the point a, when a is an + ordered pair (row,col or lat,lon) and B is an array of contiguous areas + identified by a unique integer.* All pairs should be in the same format + (row,col). + + The optional parameter b can be used to assess a subset of the areas in B + (in which case b should be a list, tuple, or 1-D arrray). By default, all + areas in B are assessed. + + The optional parameter latlon is [] by default, meaning that "closest" is + calculated as a Euclidian distance of numpy array positions. Alternatively, + latlon can be a list of two numpy arrays (lats,lons) with the same shape + as the input B. If so, the haversine formula is used to determine distance + instead. If latitude and longitude are used, then a should be a tuple of + latitude and longitude; otherwise, it should be a tuple of (row,col) + + Returns the ID of the area in B that is closest. + + *You can generate an array like this from a field of 0s and 1s using + scipy.ndimage.measurements.label(ARRAY). + ''' + if b == "all": + b = np.unique(B)[np.where(np.unique(B) != 0)] + + # First identify the shortest distance between point a and each area in B + if latlon == []: + dist = [] + for j in b: + locs1 = np.where(B == j) + locs2 = [(locs1[0][i],locs1[1][i]) for i in range(len(locs1[0]))] + dist.append(findNearestPoint(a,locs2)[1]) + else: + dist = [] + for j in b: + locs1 = np.where(B == j) + locs2 = [(latlon[0][locs1[0][i],locs1[1][i]],latlon[1][locs1[0][i],locs1[1][i]]) for i in range(len(locs1[0]))] + dist.append(findNearestPoint(a,locs2,1)[1]) + + # Then identify the shortest shortest distance + return b[np.argmin(dist)] + +'''########################### +Leap Year Boolean Creation +###########################''' +def leapyearBoolean(years): + ''' + Given a list of years, this function will identify which years are leap + years and which years are not. Returns a list of 0s (not a leap year) and + 1s (leap year). Each member of the year list must be an integer or float. + + Requires numpy. + ''' + ly = [] # Create empty list + for y in years: # For each year... + if (y%4 == 0) and (y%100!= 0): # If divisible by 4 but not 100... + ly.append(1) # ...it's a leap year + elif y%400 == 0: # If divisible by 400... + ly.append(1) # ...it's a leap year + else: # Otherwise... + ly.append(0) # ...it's NOT a leap year + + return ly + +'''########################### +Calculate Days Between Two Dates +###########################''' +def daysBetweenDates(date1,date2,lys=1,dpy=365,nmons=12): + ''' + Calculates the number of days between date1 (inclusive) and date2 (exclusive) + when given dates in list format [year,month,day,hour,minute,second] or + [year,month,day]. Works even if one year is BC (BCE) and the other is AD (CE). + If hours are used, they must be 0 to 24. Requires numpy. + + date1 = the start date (earlier in time; entire day included in the count if time of day not specified)\n + date2 = the end date (later in time; none of day included in count unless time of day is specified) + + lys = 0 for no leap years, 1 for leap years (+1 day in Feb) as in the Gregorian calendar + dpy = days per year in a non-leap year (defaults to 365) + nmons = number of months per year; if not using the Gregorian calendar, this must be a factor of dpy + ''' + if dpy == 365: + db4 = [0,31,59,90,120,151,181,212,243,273,304,334] # Number of days in the prior months + else: + db4 = list(np.arange(nmons)*int(dpy/nmons)) + + if date1[0] == date2[0]: # If the years are the same... + # 1) No intervening years, so ignore the year value: + daysY = 0 + + else: # B) If the years are different... + + # 1) Calculate the total number of days based on the years given: + years = range(date1[0],date2[0]) # make a list of all years to count + years = [yr for yr in years if yr != 0] + if lys==1: + lyb = leapyearBoolean(years) # annual boolean for leap year or not leap year + else: + lyb = [0] + + daysY = dpy*len(years)+np.sum(lyb) # calculate number of days + + if lys == 1: + ly1 = leapyearBoolean([date1[0]])[0] + ly2 = leapyearBoolean([date2[0]])[0] + else: + ly1, ly2 = 0, 0 + + # 2) Calcuate the total number of days to subtract from start year + days1 = db4[date1[1]-1] + date1[2] -1 # days in prior months + prior days in current month - the day you're starting on + # Add leap day if appropriate: + if date1[1] > 2: + days1 = days1 + ly1 + + # 3) Calculate the total number of days to add from end year + days2 = db4[date2[1]-1] + date2[2] - 1 # days in prior months + prior days in current month - the day you're ending on + # Add leap day if appropriate: + if date2[1] > 2: + days2 = days2 + ly2 + + # 4) Calculate fractional days (hours, minutes, seconds) + day1frac, day2frac = 0, 0 + + if len(date1) == 6: + day1frac = (date1[5] + date1[4]*60 + date1[3]*3600)/86400. + elif len(date1) != 3: + raise Exception("date1 does not have the correct number of values.") + + if len(date2) == 6: + day2frac = (date2[5] + date2[4]*60 + date2[3]*3600)/86400. + elif len(date2) != 3: + raise Exception("date2 does not have the correct number of values.") + + # 5) Final calculation + days = daysY - days1 + days2 - day1frac + day2frac + + return days + +'''########################### +Add Time +###########################''' +def timeAdd(time1,time2,lys=1,dpy=365): + '''This function takes the sum of two times in the format [Y,M,D,H,M,S]. + The variable time1 should be a proper date (Months are 1 to 12, Hours are 0 to 23), + but time2 does not have to be a proper date. Note that if you use months or years in time2, + the algorithm will not discrimnate the number of days per month or year. To be precise, + use only days, hours, minutes, and seconds. It can handle the BC/AD transition but + can only handle non-integers for days, hours, minutes, and seconds. To + perform date subtraction, simply make the entries in time2 negative numbers. + + lys = Boolean to determine whether to recognize leap years (1; default) + dpy = Days per yera (non leap years) -- must be 365 (default) or some multiple of 30 (e.g., 360). + --> Note: If there are leap years (lys = 1), dpy is forced to be 365 + + Addition Examples: + [2012,10,31,17,44,54] + [0,0,0,6,15,30] = [2012,11,1,0,0,24] #basic example + [2012,10,31,17,44,54] + [0,0,0,0,0,22530] = [2012,11,1,0,0,24] #time2 is improper time + + [1989,2,25,0,0,0] + [0,5,0,0,0,0] = [1989,7,25,0,0,0] #non-leap year months + [1988,2,25,0,0,0] + [0,5,0,0,0,0] = [1988,7,25,0,0,0] #leap year months + + [1989,2,25,0,0,0] + [0,0,150,0,0,0] = [1989,7,25,0,0,0] #non-leap year days + [1988,2,25,0,0,0] + [0,0,150,0,0,0] = [1988,7,24,0,0,0] #leap year days + + [1989,7,25,0,0,0] + [4,0,0,0,0] = [1993,7,25,0,0,0] #non-leap year years + [1988,7,25,0,0,0] + [4,0,0,0,0] = [1992,7,25,0,0,0] #leap year years + + [-1,12,31,23,59,59] + [0,0,0,0,0,1] = [1,1,1,0,0,0] #crossing BC/AD with seconds + [-2,1,1,0,0,0] + [4,0,0,0,0,0] = [3,1,1,0,0,0] #crossing BC/AD with years + + [1900,9,30,12,0,0] + [0,0,0.25,0,0,0] = [1900, 9, 30.0, 18, 0, 0.0] #fractional days + [1900,9,30,12,0,0] + [0,0,0.5,0,0,0] = [1900, 10, 1.0, 0, 0, 0.0] #fractional days + + Subtraction Examples: + [2012,10,31,17,44,54] - [0,0,0,-10,-50,-15] = [2012,10,31,6,54,39] #basic example + [2012,10,31,17,44,54] - [0,0,0,0,0,-39015] = [2012,10,31,6,54,39] #time2 is imporper time + + [1989,7,25,0,0,0] + [0,-5,0,0,0,0] = [1989,2,25,0,0,0] #non-leap year months + [1988,7,25,0,0,0] + [0,-5,0,0,0,0] = [1988,2,25,0,0,0] #leap year months + + [1989,7,25,0,0,0] + [0,0,-150,0,0,0] = [1989,2,25,0,0,0] #non-leap year days + [1988,7,25,0,0,0] + [0,0,-150,0,0,0] = [1988,2,26,0,0,0] #leap year days + + [1993,2,25,0,0,0] + [-4,0,0,0,0] = [1989,2,25,0,0,0] #non-leap year years + [1992,2,25,0,0,0] + [-4,0,0,0,0] = [1988,2,25,0,0,0] #leap year years + + [1,1,1,0,0,0] + [0,0,0,0,0,-1] = [-1,12,31,23,59,59] #crossing BC/AD with seconds + [2,1,1,0,0,0] + [-4,0,0,0,0,0] = [-3,1,1,0,0,0] #crossing BC/AD with years + + [1900,9,30,12,0,0] + [0,0,0.25,0,0,0] = [1900, 9, 30.0, 6, 0, 0.0] #fractional days + [1900,9,30,12,0,0] + [0,0,0.5,0,0,0] = [1900, 9, 29.0, 0, 0, 0.0] #fractional days + ''' + if len(time1) == 3: + time1 = time1 + [0,0,0] + if len(time2) == 3: + time2 = time2 + [0,0,0] + + # Ensure that years and months are whole numbers: + if time1[0]%1 != 0 or time1[1]%1 != 0 or time2[0]%1 != 0 or time2[1]%1 != 0: + raise ValueError("The year and month entries are not all whole numbers.") + + else: + # Identify Fractional days: + day1F = time1[2]%1 + day2F = time2[2]%1 + + # Initial Calculation: Add, transfer appropriate amount to next place, keep the remainder + secR = (time1[5] + time2[5] + (day1F+day2F)*86400)%60 + minC = np.floor((time1[5] + time2[5] + (day1F+day2F)*86400)/60) + + minR = (time1[4] + time2[4] + minC)%60 + hrsC = np.floor((time1[4] + time2[4] + minC)/60) + + hrsR = (time1[3] + time2[3] + hrsC)%24 + dayC = np.floor((time1[3] + time2[3] + hrsC)/24) + + dayA = (time1[2]-day1F) + (time2[2]-day2F) + dayC # Initially, just calculate days + + monA = (time1[1]-1 + time2[1])%12 + 1 # Because there is no month 0 + yrsC = np.floor((time1[1]-1 + time2[1])/12) + + yrsA = time1[0] + time2[0] + yrsC + + ###################### + #### REFINEMENTS #### + dpm = [31,28,31,30,31,30,31,31,30,31,30,31] # days per month + dpmA = [d for d in dpm] # make modifiable copy + + ### Gregorian Calendar ### + if lys == 1: + ### Deal with BC/AD ### + if time1[0] < 0 and yrsA >= 0: # Going from BC to AD + yrsR = yrsA + 1 + elif time1[0] > 0 and yrsA <= 0: # Going from AD to BC + yrsR = yrsA - 1 + else: + yrsR = yrsA + + ### Deal with Days ### + dpmA[1] = dpmA[1] + leapyearBoolean([yrsR])[0] # days per month adjusted for leap year (if applicable) + + if dayA > 0: # if the number of days is positive + if dayA <= dpmA[monA-1]: # if the number of days is positive and less than the full month... + dayR = dayA #...no more work needed + monR = monA + + elif dayA <= sum(dpmA[monA-1:]): # if the number of days is positive and over a full month but not enough to carry over to the next year... + monR = monA + dayR = dayA + while dayR > dpmA[monR-1]: # then walk through each month, subtracting days as you go until there's less than a month's worth + dayR = dayR - dpmA[monR-1] + monR = monR+1 + + else: # if the number of days is positive and will carry over to another year... + dayR = dayA - sum(dpmA[monA-1:]) # go to Jan 1 of next year... + yrsR = yrsR+1 + ly = leapyearBoolean([yrsR])[0] + while dayR > 365+ly: # and keep subtracting 365 or 366 (leap year dependent) until until no longer possible + dayR = dayR - (365+ly) + yrsR = yrsR+1 + if yrsR == 0: # Disallow 0-years + yrsR = 1 + ly = leapyearBoolean([yrsR])[0] + + dpmB = [d for d in dpm] + dpmB[1] = dpmB[1] + ly + monR = 1 + while dayR > dpmB[monR-1]: # then walk through each month + dayR = dayR - dpmB[monR-1] + monR = monR+1 + + elif dayA == 0: # if the number of days is 0 + if monA > 1: + monR = monA-1 + dayR = dpmA[monR-1] + else: + monR = 12 + dayR = 31 + yrsR = yrsR - 1 + + else: # if the number of days is negative + if abs(dayA) < sum(dpmA[:monA-1]): # if the number of days will stay within the same year... + monR = monA + dayR = dayA + while dayR <= 0: + monR = monR-1 + dayR = dpmA[monR-1] + dayR + + else: # if the number of days is negative and will cross to prior year... + dayR = dayA + sum(dpmA[:monA-1]) + yrsR = yrsR-1 + if yrsR == 0: + yrsR = -1 + + ly = leapyearBoolean([yrsR])[0] + while abs(dayR) >= 365+ly: + dayR = dayR + (365+ly) + yrsR = yrsR-1 + ly = leapyearBoolean([yrsR])[0] + + dpmB = [d for d in dpm] + dpmB[1] = dpmB[1] + ly + monR = 13 + dayR = dayR + while dayR <= 0: + monR = monR-1 + dayR = dpmB[monR-1] + dayR + + ### 365-Day Calendar ### + elif dpy == 365: + if dayA > 0: # if the number of days is positive + if dayA <= dpmA[monA-1]: # if the number of days is positive and less than the full month... + dayR = dayA #...no more work needed + monR = monA + + elif dayA <= sum(dpmA[monA-1:]): # if the number of days is positive and over a full month but not enough to carry over to the next year... + monR = monA + dayR = dayA + while dayR > dpmA[monR-1]: # then walk through each month, subtracting days as you go until there's less than a month's worth + dayR = dayR - dpmA[monR-1] + monR = monR+1 + + else: # if the number of days is positive and will carry over to another year... + dayR = dayA - sum(dpmA[monA-1:]) # go to Jan 1 of next year... + yrsA = yrsA+1 + while dayR > 365: # and keep subtracting 365 until until no longer possible + dayR = dayR - 365 + yrsA = yrsA+1 + + dpmB = [d for d in dpm] + dpmB[1] = dpmB[1] + monR = 1 + while dayR > dpmB[monR-1]: # then walk through each month + dayR = dayR - dpmB[monR-1] + monR = monR+1 + + elif dayA == 0: # if the number of days is 0 + if monA > 1: + monR = monA-1 + dayR = dpmA[monR-1] + else: + monR = 12 + dayR = 31 + yrsA = yrsA -1 + + else: # if the number of days is negative + if abs(dayA) < sum(dpmA[:monA-1]): # if the number of days will stay within the same year... + monR = monA + dayR = dayA + while dayR <= 0: + monR = monR-1 + dayR = dpmA[monR-1] + dayR + + else: # if the number of days is negative and will cross to prior year... + dayR = dayA + sum(dpmA[:monA-1]) + yrsA = yrsA-1 + while abs(dayR) >= 365: + dayR = dayR + 365 + yrsA = yrsA-1 + + dpmB = [d for d in dpm] + dpmB[1] = dpmB[1] + monR = 13 + dayR = dayR + while dayR <= 0: + monR = monR-1 + dayR = dpmB[monR-1] + dayR + + ### Deal with BC/AD ### + if time1[0] < 0 and yrsA >= 0: # Going from BC to AD + yrsR = yrsA + 1 + elif time1[0] > 0 and yrsA <= 0: # Going from AD to BC + yrsR = yrsA - 1 + else: + yrsR = yrsA + + ### 360-Day Calendar ### (or other mulitple of 30) ### + else: + if dayA > 0: # if the number of days is positive + if dayA <= 30: # if the number of days is positive and less than the full month... + monR = monA + dayR = dayA #...no more work needed + + elif (dayA + (monA-1)*30) <= dpy: # if the number of days is positive and over a full month but not enough to carry over to the next year... + monR = monA + int(dayA/30) # add months + dayR = dayA%30 # find new day-of-month + + else: # if the number of days is positive and will carry over to another year... + yrsA = yrsA+1 + dayR = (monA-1)*30 + dayA - dpy # go to Jan 1 of next year... + + yrsA = yrsA + int(dayR/dpy) # add years + dayR = dayR%dpy # find new day-of-year + + monR = int(dayR/30) + 1 # add months + dayR = dayR%30 # find new day-of-month + + elif dayA == 0: # if the number of days is 0 + if monA > 1: + monR = monA-1 + else: + monR = int(dpy/30) + yrsA = yrsA -1 + dayR = 30 + + else: # if the number of days is negative + if abs(dayA) < (monA-1)*30: # if the number of days will stay within the same year... + monR = monA-1 + int(dayA/30) # Subtract months + dayR = dayA%30 # Find new number of days + + else: # if the number of days is negative and will cross to prior year... + yrsA = yrsA-1 + dayR = dayA + (monA-1)*30 # go to Dec 30 of prior year + + # find new day of year + yrsA = yrsA + int(dayR/dpy) # subtract years + dayR = dayR%dpy # find new day of year (switches to positive) + + monR = int(dayR/30) + 1 # add months + dayR = dayR%30 # find new day of month + + if dayR == 0: + if monR == 1: + yrsA = yrsA-1 + monR = int(dpy/30) + else: + monR = monR-1 + dayR = 30 + + ### Deal with BC/AD ### + if time1[0] < 0 and yrsA >= 0: # Going from BC to AD + yrsR = yrsA + 1 + elif time1[0] > 0 and yrsA <= 0: # Going from AD to BC + yrsR = yrsA - 1 + else: + yrsR = yrsA + + return [int(yrsR),int(monR),int(dayR),int(hrsR),int(minR),secR] + +'''########################### +Calculate a Latitudinal Angle along a Certain Distance of a Meridian +###########################''' +def dist2lat(d,units="km", r=6371.): + '''This function converts from standard distance units to a latitudinal + angle on a sphere when given its radius. By default, the distance is assumed + to be in kilometers and the radius is assumed to be 6371 km (i.e., the + sphere is Earth). Returns the latitudinal angle in degrees. Note: this + function only works if working strictly meridional. + ''' + import numpy as np + + # Other Conversions: + km = ["km","kms","kilometer","kilometre","kilometers","kilometres"] + m = ["m","ms","meter","meters","metres","metres"] + ft = ["ft","feet"] + mi = ["mi","miles","mile","mi"] + nm = ["nm","nautical mile","nms","nautical miles","mile nautical",\ + "miles nautical","mile (nautical)","miles (nautical)"] + + if units.lower() in km: + d = d + elif units.lower() in ft: + d = d/3280.84 + elif units.lower() in mi: + d = d/0.621371 + elif units.lower() in nm: + d = d/0.5399568 + elif units.lower() in m: + d = d/1000 + + # Main calculation + return (180/np.pi)*(d/r) + +'''########################### +Calculate a Longitudinal Angle along a Certain Distance of a Parallel +###########################''' +def dist2lon(d, lat1, lat2, units="km", r=6371.): + '''This function converts from standard distance units to a longitudinal + angle on a sphere when given two latitudes (in degrees) and the radius of + the sphere using the haversine formula. By default, the distance is assumed + to be in kilometers and the radius is assumed to be 6371 km (i.e., the + sphere is Earth). Returns the longitudinal angle in degrees. + ''' + import numpy as np + + # Convert latitudes to radians: + lat1, lat2 = lat1*np.pi/180, lat2*np.pi/180 + + # Other Conversions: + km = ["km","kms","kilometer","kilometre","kilometers","kilometres"] + m = ["m","ms","meter","meters","metres","metres"] + ft = ["ft","feet"] + mi = ["mi","miles","mile","mi"] + nm = ["nm","nautical mile","nms","nautical miles","mile nautical",\ + "miles nautical","mile (nautical)","miles (nautical)"] + + if units.lower() in km: + d = d + elif units.lower() in ft: + d = d/3280.84 + elif units.lower() in mi: + d = d/0.621371 + elif units.lower() in nm: + d = d/0.5399568 + elif units.lower() in m: + d = d/1000 + + # Main calculation + dlat = lat2 - lat1 + c = d/r + dlon = 2*np.arcsin(( ( np.sin(c/2)**2 - np.sin(dlat/2)**2 ) / ( np.cos(lat1)*np.cos(lat2) ) )**0.5)*180.0/np.pi + #dlon = 2*np.arcsin( np.sin(c/2) / np.cos(lat1) ) # for constant latitude + + return dlon*np.sign(d) + +'''########################### +Add Degrees of Latitude +###########################''' +def addLat(lat0,dlat): + '''Adds a change in latitude to an initial latitude; can handle crossing + the poles.\n + lat0 = initial latitude (degrees) + dlat = change in latitude (degrees) + ''' + lat1 = lat0 + dlat + if lat1 > 90: + lat1 = 180 - lat1 + elif lat1 < -90: + lat1 = -180 - lat1 + return lat1 + +'''########################### +Add Degrees of Longitude +###########################''' +def addLon(lon0,dlon,neglon=1): + '''Adds a change in longitude to an initial longitude; can handle crossing + the -180/180 or 0/360 boundary.\n + lon0 = initial longitude + dlon = change in longitude + neglon = binary; 1 means longitude goes from -180 to 180 (default); + 0 means longitude goes from 0 to 360 (i.e. no negative longitudes) + ''' + lon1 = lon0 + dlon + + if neglon == 0: + if lon1 > 360: + lon1 = lon1 - 360 + elif lon1 < 0: + lon1 = lon1 + 360 + else: + if lon1 > 180: + lon1 = lon1 - 360 + elif lon1 < -180: + lon1 = lon1 + 360 + + return lon1 + +'''########################### +Rotate Coordinates Around the Origin +###########################''' +def rotateCoordsAroundOrigin(x1,y1,phi): + '''This function will identify the new coordinates (x2,y2) of a point with + coordinates (x1,y1) rotated by phi (radians) around the origin (0,0). The + outputs are the two new coordinates x2 and y2. Note that if using numpy arrays, + "y" corresponds to the row position and "x" to the column position. + + x1, y1 = the x and y coordinates of the original point (integers or floats)\n + phi = the desired rotation from -pi to +pi radians + + Note: Typically, if you're going from lat/lon to x/y, you need to mulitply phi by -1 + (e.g., going from lat/lon to a Polar North Stereographic centered on -45° is + done with -1*(longitude-45)*np.pi/180) + ''' + # Calculate the distance from the origin: + c = np.sqrt(np.square(y1) + np.square(x1)) + # Rotate X coordinate: + x2 = c*np.cos(np.arctan2(y1,x1)-phi) + # Rotate Y coordinate: + y2 = c*np.sin(np.arctan2(y1,x1)-phi) + + return x2,y2 + +'''########################### +Ring Kernel Creation +###########################''' +def ringKernel(ri,ro,d=0): + '''Given two radii in numpy array cells, this function will calculate a + numpy array of 1 and nans where 1 is the cells whose centroids are more than + ri units away from the center centroid but no more than ro units away. The + result is a field of nans with a ring of 1s. + + ri = inner radius in numpy array cells (integer or float) + ro = outer radius in numpy array cells (integer or float) + d = if d==0, then function returns an array of 1s and nans + if d==1, then function returns an array of distances from + center (in array cells) + + ''' + # Create a numpy array of 1s: + k = int(ro*2+1) + ringMask=np.ones((k,k)) + + # If returing 1s and nans: + if d == 0: + for row in range(0,k): + for col in range(0,k): + d = ((row-ro)**2 + (col-ro)**2)**0.5 + if d > ro or d <= ri: + ringMask[row,col]=np.NaN + return ringMask + + # If returning distances: + if d == 1: + ringDist = np.zeros((k,k)) + for row in range(0,k): + for col in range(0,k): + ringDist[row,col] = ((row-ro)**2 + (col-ro)**2)**0.5 + if ringDist[row,col] > ro or ringDist[row,col] <= ri: + ringMask[row,col]=np.NaN + ringDist = ringMask*ringDist + return ringDist + +def ringDistance(ydist, xdist, rad): + '''Given the grid cell size in the x and y direction and a desired radius, + creates a numpy array for which the distance from the center is recorded + in all cells between a distance of rad and rad-mean(ydist,xdist) and all + other cells are np.nan. + + ydist, xdist = the grid cell size in the x and y direction + rad = the desired radius -- must be same units as xdist and ydist + ''' + # number of cells in either cardinal direction of the center of the kernel + kradius = np.int( np.ceil( rad / min(ydist,xdist) ) ) + # number of cells in each row and column of the kernel + kdiameter = kradius*2+1 + + # for each cell, calculate x distances and y distances from the center + kxdists = np.tile(np.arange(-kradius, kradius + 1), (kdiameter, 1)) * xdist + kydists = np.rot90(np.tile(np.arange(-kradius, kradius + 1), (kdiameter, 1)) * ydist) + + # apply pythagorean theorem to calculate euclidean distances + kernel = np.sqrt(np.square(kxdists) + np.square(kydists)) + + # create a boolean mask which determines the cells that should be nan + mask = (kernel > rad) | (kernel <= (rad - np.mean((ydist, xdist)))) + + kernel[mask] = np.nan + + return kernel + +'''########################### +Circle Kernel Creation +###########################''' +def circleKernel(r,masked_value=np.nan): + '''Given the radius in numpy array cells, this function will calculate a + numpy array of 1 and some other value where 1 is the cells whose centroids + are less than the radius away from the center centroid. + + r = radius in numpy array cells (integer or float) + masked_value = value to use for cells whose centroids are more than r + distnace away from the center centroid. np.nan by default + ''' + # Create a numpy array of 1s: + rc = int(np.ceil(r)) + k = rc*2+1 + circleMask=np.ones((k,k)) + for row in range(0,k): + for col in range(0,k): + d = ((row-rc)**2 + (col-rc)**2)**0.5 + if d > r: + circleMask[row,col]=masked_value + return circleMask + +'''########################### +Calculate Mean Gradient from Center Based on a Kernel +###########################''' +def kernelGradient(field,location,yDist,xDist,rad): + '''Given a field and a tuple or list of (row,col), this function + calculates the difference between the location and each gridcell in a + subset that matches the kernel. It then calculates the gradient using + the x and y distances and calculates the mean for all gridcells in the + kernel. + + field = a numpy array of floats or integers + location = a tuple or list in the format (row,col) in array coordinates + yDist = a numpy array of distances between rows + xDist = a numpy array of distance between columns + rad = radius in real units (must match units of yDist and xDist) + + Returns a mean gradient in field units per radius units + ''' + # Define row & col + row, col = location[0], location[1] + + # Define ring distance kernel & kernel size + kernel = ringDistance(yDist[row,col],xDist[row,col],rad) + ksize = int((kernel.shape[0]-1)/2) + + # Define the starting and stopping rows and cols: + rowS = row-ksize + rowE = row+ksize + colS = col-ksize + colE = col+ksize + if rowS < 0: + rowS = 0 + if rowE > field.shape[0]: + rowE = field.shape[0] + if colS < 0: + colS = 0 + if colE > field.shape[1]: + colE = field.shape[1] + + # Take a subset to match the kernel size + subset = field[rowS:rowE+1,colS:colE+1] + + # Add nans to fill out subset if not already of equal size with kernel + if kernel.shape[0] > subset.shape[0]: # If there aren't enough rows to match kernel + nanrows = np.empty( ( (kernel.shape[0]-subset.shape[0]),subset.shape[1] ) )*np.nan + if rowS == 0: # Add on top if the first row is row 0 + subset = np.vstack( (nanrows,subset) ) + else: # Add to bottom otherwise + subset = np.vstack( (subset,nanrows) ) + if kernel.shape[1] > subset.shape[1]: # If there aren't enough columns to match kernel + nancols = np.empty( (subset.shape[0], (kernel.shape[1]-subset.shape[1]) ) )*np.nan + if colS == 0: # Add to left if first col is col 0 + subset = np.hstack( (nancols,subset) ) + else: # Add to right otherwise + subset = np.hstack( (subset,nancols) ) + + # Apply kernel and calculate difference between each cell and the center + subset_ring = (subset - field[row,col])/kernel + + # Find the mean value (excluding nans) + mean = np.nanmean(subset_ring) + + return mean + +'''########################### +Calculate Sobel Gradients of Field +###########################''' +def slope_latlon(var, lat, lon, edgeLR = "calc", edgeTB = "calc", dist="km"): + '''This function returns numpy arrays of the meridional and zonal + variable gradient and its magnitude when fed numpy arrays for + the variable, latitude, and longitude at each point. A sobel operator is + used to calculate slope. Cells do not have to be square because cell size + is calculated from the latitude and longitude rasters, but cell size must + be constant. Output units are the units of var over the units of dist.\n + + var = variable field (numpy array of integers or floats); such as temperature + or humidity\n + lat = latitude field (numpy array of latitudes)\n + lon = longitude field (numpy array of longitudes)\n + dist = the units of the denominator in the output (meters by default, + also accepts kilometers, miles, or nautical miles)\n + edgeLR, edgeTB = behavior of the gradient calculation at the left and right + hand edges and top and bottom edges of the numpy array, respectively. + Choose "calc" to calculate a partial Sobel operator, "wrap" if opposite + edges are adjacent, "nan" to set the edges to a nan value, and "zero" + to set the edges to a zero value. By default, the function will calculate + a gradient based on a partial Sobel operator. Use "wrap" if the dataset + spans -180 to 180 longitude. Use "zeros" if the dataset spans -90 to + 90 latitude (i.e. distance is zero at the edge). + ''' + ################# + # Set up Arrays # + ################# + # Find shape of input field + nrow = var.shape[-2] + ncol = var.shape[-1] + + if edgeLR == "wrap" and edgeTB == "wrap": + var1 = np.vstack(( np.hstack((var[-2:,-2:], var[-2:,:])), np.hstack((var[:,-2:] , var)) )) + var2 = np.vstack(( np.hstack((var[-2:,-1:], var[-2:,:], var[-2:,0:1])), np.hstack((var[:,-1:] , var, var[:,0:1])) )) + var3 = np.vstack(( np.hstack((var[-2:,:], var[-1:,0:2])), np.hstack((var, var[:,0:2])) )) + var4 = np.vstack(( np.hstack((var[-1:,-2:], var[-1:,:])), np.hstack((var[:,-2:] , var)), np.hstack((var[0:1,-2:], var[0:1,:])) )) + var5 = np.vstack(( np.hstack((var[-1:,-1:], var[-1:,:], var[-1:,0:1])), np.hstack((var[:,-1:] , var, var[:,0:1])), np.hstack((var[0:1,-1:], var[0:1,:], var[0:1,0:1])) )) + var6 = np.vstack(( np.hstack((var[-1:,:], var[-1:,0:2])), np.hstack((var, var[:,0:2])), np.hstack((var[0:1,:], var[0:1,0:2])) )) + var7 = np.vstack(( np.hstack((var[:,-2:] , var)), np.hstack((var[0:2,-2:], var[0:2,:])) )) + var8 = np.vstack(( np.hstack((var[:,-1:] , var, var[:,0:1])), np.hstack((var[0:2,-1:], var[0:2,:], var[0:2,0:1])) )) + var9 = np.vstack(( np.hstack((var, var[:,0:2])), np.hstack((var[0:2,:], var[0:2,0:2])) )) + + lon4 = np.vstack(( np.hstack((lon[-1:,-2:], lon[-1:,:])), np.hstack((lon[:,-2:] , lon)), np.hstack((lon[0:1,-2:], lon[0:1,:])) )) + lon6 = np.vstack(( np.hstack((lon[-1:,:], lon[-1:,0:2])), np.hstack((lon, lon[:,0:2])), np.hstack((lon[0:1,:], lon[0:1,0:2])) )) + lon2 = np.vstack(( np.hstack((lon[-2:,-1:], lon[-2:,:], lon[-2:,0:1])), np.hstack((lon[:,-1:] , lon, lon[:,0:1])) )) + lon8 = np.vstack(( np.hstack((lon[:,-1:] , lon, lon[:,0:1])), np.hstack((lon[0:2,-1:], lon[0:2,:], lon[0:2,0:1])) )) + + lat4 = np.vstack(( np.hstack((lat[-1:,-2:], lat[-1:,:])), np.hstack((lat[:,-2:] , lat)), np.hstack((lat[0:1,-2:], lat[0:1,:])) )) + lat6 = np.vstack(( np.hstack((lat[-1:,:], lat[-1:,0:2])), np.hstack((lat, lat[:,0:2])), np.hstack((lat[0:1,:], lat[0:1,0:2])) )) + lat2 = np.vstack(( np.hstack((lat[-2:,-1:], lat[-2:,:], lat[-2:,0:1])), np.hstack((lat[:,-1:] , lat, lat[:,0:1])) )) + lat8 = np.vstack(( np.hstack((lat[:,-1:] , lat, lat[:,0:1])), np.hstack((lat[0:2,-1:], lat[0:2,:], lat[0:2,0:1])) )) + + elif edgeLR == "wrap" and edgeTB != "wrap": + var1 = np.vstack(( np.zeros((2,ncol+2)), np.hstack((var[:,-2:] , var)) )) + var2 = np.vstack(( np.zeros((2,ncol+2)), np.hstack((var[:,-1:] , var, var[:,0:1])) )) + var3 = np.vstack(( np.zeros((2,ncol+2)), np.hstack((var, var[:,0:2])) )) + var4 = np.vstack(( np.zeros((1,ncol+2)), np.hstack((var[:,-2:] , var)), np.zeros((1,ncol+2)) )) + var5 = np.vstack(( np.zeros((1,ncol+2)), np.hstack((var[:,-1:] , var, var[:,0:1])), np.zeros((1,ncol+2)) )) + var6 = np.vstack(( np.zeros((1,ncol+2)), np.hstack((var, var[:,0:2])), np.zeros((1,ncol+2)) )) + var7 = np.vstack(( np.hstack((var[:,-2:] , var)), np.zeros((2,ncol+2)) )) + var8 = np.vstack(( np.hstack((var[:,-1:] , var, var[:,0:1])), np.zeros((2,ncol+2)) )) + var9 = np.vstack(( np.hstack((var, var[:,0:2])), np.zeros((2,ncol+2)) )) + + lon4 = np.vstack(( np.zeros((1,ncol+2)), np.hstack((lon[:,-2:] , lon)), np.zeros((1,ncol+2)) )) + lon6 = np.vstack(( np.zeros((1,ncol+2)), np.hstack((lon, lon[:,0:2])), np.zeros((1,ncol+2)) )) + lon2 = np.vstack(( np.zeros((2,ncol+2)), np.hstack((lon[:,-1:] , lon, lon[:,0:1])) )) + lon8 = np.vstack(( np.hstack((lon[:,-1:] , lon, lon[:,0:1])), np.zeros((2,ncol+2)) )) + + lat4 = np.vstack(( np.zeros((1,ncol+2)), np.hstack((lat[:,-2:] , lat)), np.zeros((1,ncol+2)) )) + lat6 = np.vstack(( np.zeros((1,ncol+2)), np.hstack((lat, lat[:,0:2])), np.zeros((1,ncol+2)) )) + lat2 = np.vstack(( np.zeros((2,ncol+2)), np.hstack((lat[:,-1:] , lat, lat[:,0:1])) )) + lat8 = np.vstack(( np.hstack((lat[:,-1:] , lat, lat[:,0:1])), np.zeros((2,ncol+2)) )) + + elif edgeLR != "wrap" and edgeTB == "wrap": + var1 = np.hstack(( np.zeros((nrow+2,2)) , np.vstack((var[-2:,:],var)) )) + var2 = np.hstack(( np.zeros((nrow+2,1)) , np.vstack((var[-2:,:],var)) , np.zeros((nrow+2,1)) )) + var3 = np.hstack(( np.vstack((var[-2:,:],var)) , np.zeros((nrow+2,2)) )) + var4 = np.hstack(( np.zeros((nrow+2,2)) , np.vstack((var[-1:,:],var,var[0:1,:])) )) + var5 = np.hstack(( np.zeros((nrow+2,1)) , np.vstack((var[-1:,:],var,var[0:1,:])) , np.zeros((nrow+2,1)) )) + var6 = np.hstack(( np.vstack((var[-1:,:],var,var[0:1,:])), np.zeros((nrow+2,2)) )) + var7 = np.hstack(( np.zeros((nrow+2,2)) , np.vstack((var,var[0:2,:])) )) + var8 = np.hstack(( np.zeros((nrow+2,1)) , np.vstack((var,var[0:2,:])) , np.zeros((nrow+2,1)) )) + var9 = np.hstack(( np.vstack((var,var[0:2,:])) , np.zeros((nrow+2,2)) )) + + lat4 = np.hstack(( np.zeros((nrow+2,2)) , np.vstack((lat[-1:,:],lat,lat[0:1,:])) )) + lat6 = np.hstack(( np.vstack((lat[-1:,:],lat,lat[0:1,:])), np.zeros((nrow+2,2)) )) + lat2 = np.hstack(( np.zeros((nrow+2,1)) , np.vstack((lat[-2:,:],lat)) , np.zeros((nrow+2,1)) )) + lat8 = np.hstack(( np.zeros((nrow+2,1)) , np.vstack((lat,lat[0:2,:])) , np.zeros((nrow+2,1)) )) + + lon4 = np.hstack(( np.zeros((nrow+2,2)) , np.vstack((lon[-1:,:],lon,lon[0:1,:])) )) + lon6 = np.hstack(( np.vstack((lon[-1:,:],lon,lon[0:1,:])), np.zeros((nrow+2,2)) )) + lon2 = np.hstack(( np.zeros((nrow+2,1)) , np.vstack((lon[-2:,:],lon)) , np.zeros((nrow+2,1)) )) + lon8 = np.hstack(( np.zeros((nrow+2,1)) , np.vstack((lon,lon[0:2,:])) , np.zeros((nrow+2,1)) )) + + else: + var1 = np.hstack(( np.zeros((nrow+2,2)) , np.vstack((np.zeros((2,ncol)),var)) )) + var2 = np.hstack(( np.zeros((nrow+2,1)) , np.vstack((np.zeros((2,ncol)),var)) , np.zeros((nrow+2,1)) )) + var3 = np.hstack(( np.vstack((np.zeros((2,ncol)),var)) , np.zeros((nrow+2,2)) )) + var4 = np.hstack(( np.zeros((nrow+2,2)) , np.vstack((np.zeros((1,ncol)),var,np.zeros((1,ncol)))) )) + var5 = np.hstack(( np.zeros((nrow+2,1)) , np.vstack((np.zeros((1,ncol)),var,np.zeros((1,ncol)))) , np.zeros((nrow+2,1)) )) + var6 = np.hstack(( np.vstack((np.zeros((1,ncol)),var,np.zeros((1,ncol)))), np.zeros((nrow+2,2)) )) + var7 = np.hstack(( np.zeros((nrow+2,2)) , np.vstack((var,np.zeros((2,ncol)))) )) + var8 = np.hstack(( np.zeros((nrow+2,1)) , np.vstack((var,np.zeros((2,ncol)))) , np.zeros((nrow+2,1)) )) + var9 = np.hstack(( np.vstack((var,np.zeros((2,ncol)))) , np.zeros((nrow+2,2)) )) + + lat2 = np.hstack(( np.zeros((nrow+2,1)) , np.vstack((np.zeros((2,ncol)),lat)) , np.zeros((nrow+2,1)) )) + lat4 = np.hstack(( np.zeros((nrow+2,2)) , np.vstack((np.zeros((1,ncol)),lat,np.zeros((1,ncol)))) )) + lat6 = np.hstack(( np.vstack((np.zeros((1,ncol)),lat,np.zeros((1,ncol)))), np.zeros((nrow+2,2)) )) + lat8 = np.hstack(( np.zeros((nrow+2,1)) , np.vstack((lat,np.zeros((2,ncol)))) , np.zeros((nrow+2,1)) )) + + lon2 = np.hstack(( np.zeros((nrow+2,1)) , np.vstack((np.zeros((2,ncol)),lon)) , np.zeros((nrow+2,1)) )) + lon4 = np.hstack(( np.zeros((nrow+2,2)) , np.vstack((np.zeros((1,ncol)),lon,np.zeros((1,ncol)))) )) + lon6 = np.hstack(( np.vstack((np.zeros((1,ncol)),lon,np.zeros((1,ncol)))), np.zeros((nrow+2,2)) )) + lon8 = np.hstack(( np.zeros((nrow+2,1)) , np.vstack((lon,np.zeros((2,ncol)))) , np.zeros((nrow+2,1)) )) + + ############################## + # Calculate Slope Components # + ############################## + # Find Cell Distances + dX = haversine(lat4,lat6,lon4,lon6,dist) + dY = haversine(lat8,lat2,lon8,lon2,dist) + + # Perform a Sobel Operator + dZdX = ( ((var3 + 2.*var6 + var9) - (var1 + 2.*var4 + var7)) / (4.*dX) )[1:nrow+1,1:ncol+1] + dZdY = ( ((var1 + 2.*var2 + var3) - (var7 + 2.*var8 + var9)) / (4.*dY) )[1:nrow+1,1:ncol+1] + + # Edit TB Edges + if edgeTB == "calc": + dZdX[-1:,:] = ( ((var3[-2:-1,:] + 2.*var6[-2:-1,:]) - (var1[-2:-1,:] + 2.*var4[-2:-1,:])) / (3.*dX[-2:-1,:]) )[:,1:ncol+1] + dZdX[0:1,:] = ( ((2.*var6[1:2,:] + var9[1:2,:]) - (2.*var4[1:2,:] + var7[1:2,:])) / (3.*dX[1:2,:]) )[:,1:ncol+1] + dZdY[-1:,:] = ( ((var1[-2:-1,:] + 2.*var2[-2:-1,:] + var3[-2:-1,:]) - (var4[-2:-1,:] + 2.*var5[-2:-1,:] + var6[-2:-1,:])) / (2.*dY[-2:-1,:]) )[:,1:ncol+1] + dZdY[0:1,:] = ( ((var4[-2:-1,:] + 2.*var5[-2:-1,:] + var6[-2:-1,:]) - (var7[-2:-1,:] + 2.*var8[-2:-1,:] + var9[-2:-1,:])) / (4.*dY[-2:-1,:]) )[:,1:ncol+1] + elif edgeTB == "zero": + dZdX[-1:,:], dZdX[0:1,:] = 0, 0 + dZdY[-1:,:], dZdY[0:1,:] = 0, 0 + elif edgeTB == "nan": + dZdX[-1:,:], dZdX[0:1,:] = np.nan, np.nan + dZdY[-1:,:], dZdY[0:1,:] = np.nan, np.nan + + # Edit LR Edges + if edgeLR == "calc": + dZdX[:,-1:] = ( ((var2[:,-2:-1] + 2.*var5[:,-2:-1] + var8[:,-2:-1]) - (var1[:,-2:-1] + 2.*var4[:,-2:-1] + var7[:,-2:-1])) / (2.*dX[:,-2:-1]) )[1:nrow+1,:] + dZdX[:,0:1] = ( ((var3[:,1:2] + 2.*var6[:,1:2] + var9[:,1:2]) - (var2[:,1:2] + 2.*var5[:,1:2] + var8[:,1:2])) / (2.*dX[:,1:2]) )[1:nrow+1,:] + dZdY[:,-1:] = ( ((var1[:,-2:-1] + 2.*var2[:,-2:-1]) - (var7[:,-2:-1] + 2.*var8[:,-2:-1])) / (3.*dY[:,-2:-1]) )[1:nrow+1,:] + dZdY[:,0:1] = ( ((2.*var2[:,1:2] + var3[:,1:2]) - (2.*var8[:,1:2] + var9[:,1:2])) / (3.*dY[:,1:2]) )[1:nrow+1,:] + elif edgeLR == "zero": + dZdX[:,-1:], dZdX[:,0:1] = 0, 0 + dZdY[:,-1:], dZdY[:,0:1] = 0, 0 + elif edgeTB == "nan": + dZdX[-1:,:], dZdX[0:1,:] = np.nan, np.nan + dZdY[-1:,:], dZdY[0:1,:] = np.nan, np.nan + + # Clean-up for cells that have lat of +/- 90 + cleaner = np.where(np.abs(lat) > 89.95,0,1) + dZdX, dZdY = dZdX*cleaner, dZdY*cleaner # such cells are set to a gradient of 0! + + # Slope Magnitude + slope = np.sqrt( np.square(dZdX) + np.square(dZdY) ) + + return slope, dZdX, dZdY + +'''########################### +Calculate Laplacian of Field +###########################''' +def laplacian(field,multiplier=1): + '''Given a field, this function calculates the Laplacian (similar to the field's + second derivative over two dimensions). The gradient is calculated using a + Sobel operator, so edge effects do exist. Following the method of Murray + and Simmonds (1991), the second derivative is first calculated for the x + and y orthognoal components individally and then combined to giv a divergence. + + field = a numpy array of values over which to take the Laplacian\n + multiplier (optional) = an optional multiplier for converting units at the + end of the calculation. For example to convert from Pa/[100 km]^2 to + hPa/[100 km]^2, use 0.01. The default is 1. + ''' + # Calculate gradient with sobel operator + sobY = ndimage.sobel(field,axis=0,mode='constant',cval=np.nan)/-8 + sobX = ndimage.sobel(field,axis=1,mode='constant',cval=np.nan)/8 + + # Calcualte gradient of the gradient with sobel again + lapY = ndimage.sobel(sobY,axis=0,mode='constant',cval=np.nan)/-8 + lapX = ndimage.sobel(sobX,axis=1,mode='constant',cval=np.nan)/8 + + # Add components + laplac = (lapY + lapX)*multiplier + + return laplac + +def laplacian_latlon(field,lats,lons,edgeLR = "calc", edgeTB = "calc", dist="km"): + '''Given a field, this function calculates the Laplacian (the field's + second derivative over two dimensions). The gradient is calculated using a + Sobel operator, so edge effects do exist. Following the method of Murray + and Simmonds (1991), the second derivative is first calculated for the x + and y orthogonal components individally and then combined. + + field = a numpy array of values over which to take the Laplacian\n + lats = a numpy array of latitudes (shape should match field)\n + lons = a numpy array of longitudes (shape should match field)\n + dist = the units of the denominator in the output (meters by default, + also accepts kilometers, miles, or nautical miles)\n + edgeLR, edgeTB = behavior of the gradient calculation at the left and right + hand edges and top and bottom edges of the nupy array, respectively. + Choose "calc" to claculate a partial Sobel operator, "wrap" if opposite + edges are adjacent, "nan" to set the edges to a nan value, and "zero" + to set the edges to a zero value. By default, the function will calculate + a gradient based on a partial Sobel operator. Use "wrap" if the dataset + spans -180 to 180 longitude. Use "zeros" if the dataset spans -90 to + 90 latitude (i.e. distance is zero at the edge). + ''' + # First Gradient + slopeX, slopeY = slope_latlon(field, lats, lons, edgeLR, edgeTB, dist)[1:] + + # Second Gradient + laplacX = slope_latlon(slopeX, lats, lons, edgeLR, edgeTB, dist)[1] + laplacY = slope_latlon(slopeY, lats, lons, edgeLR, edgeTB, dist)[2] + + return laplacX + laplacY + +'''########################### +Create Array Neighborhood +###########################''' +def arrayNeighborhood(var,kSize=3,edgeLR=0,edgeTB=0): + ''' + ''' + k = kSize-1 + nrow = var.shape[0] + ncol = var.shape[1] + + varcube = [] + + if edgeLR != "wrap" and edgeTB != "wrap": + for i in range(kSize): + for j in range(kSize): + varcube.append( np.hstack(( np.zeros((nrow+k,j))+edgeLR , np.vstack(( np.zeros((k-i,ncol))+edgeTB, var, np.zeros((i,ncol))+edgeTB )) , np.zeros((nrow+k,k-j))+edgeLR )) ) + + elif edgeLR == "wrap" and edgeTB == "wrap": + for i in range(kSize): + for j in range(kSize): + varcube.append( np.vstack(( np.hstack((var[-(k-i):,-(k-j):] , var[-(k-i):,:], var[-(k-i):,0:j])), np.hstack((var[:,-(k-j):], var, var[:,0:j])) , np.hstack((var[0:i,-(k-j):], var[0:i,:], var[0:i,0:j])) ))[:nrow+k,:ncol+k] ) + + elif edgeLR == "wrap" and edgeTB != "wrap": + for i in range(kSize): + for j in range(kSize): + varcube.append( np.vstack(( np.zeros((i,ncol+k))+edgeTB , np.hstack((var[:,-(k-j):], var, var[:,0:j]))[:,:ncol+k], np.zeros(((k-i),ncol+k))+edgeTB )) ) + + else: + for i in range(kSize): + for j in range(kSize): + varcube.append( np.hstack(( np.zeros((nrow+k,j))+edgeLR , np.vstack((var[-(k-i):,:], var, var[0:i,:]))[:nrow+k,:] , np.zeros((nrow+k,(k-j)))+edgeLR )) ) + + return np.array(varcube) + +'''########################### +Detect Minima and Maxima +###########################''' +def detectMinima(var, mask, kSize=3, nanthreshold=1): + '''Identifies local minima in a numpy array (surface) by searching within a + square kernel (the neighborhood) for each grid cell. Ignores nans by default. + + field = a numpy array that represents some surface of interest + mask = a numpy array of 0s and NaNs used to mask results -- can repeat var + input here if no mask exists + kSize = kernel size (e.g., 3 means a 3*3 kernel centered on each grid cell) + nanthreshold = maximum ratio of nan cells to total cells in the kernel for + each minimum test. 0 means that no cell with a nan neighbor can be + considered a minimum. 0.5 means that less than half of the cells in the + kernel can be nans for a cell to be considered a minimum. 1 means + that a cell will be considered a minimum even if all cells around it + are nan. Warning: since the center cell of the kernel is included in + the ratio, if you want to make decisions based on the ratio of nerighbors + that exceed some threshold, scale your desired threshold as such: + nanthreshold = desiredthreshold * (kSize*kSize-1)/(kSize*kSize). + ''' + # Find percentage of NaNs in each neighborhood + nancount = ndimage.uniform_filter(np.isnan(mask).astype(float),kSize) + + # Find the local minima + output = ndimage.minimum_filter(var,kSize) + + # export valid locations as 1s and invalid locations as 0s + return (output == var) & (nancount < nanthreshold) & np.isfinite(mask) + +def detectMaxima(var, mask, kSize=3, nanthreshold=1): + '''Identifies local maxima in a numpy array (surface) by searching within a + square kernel (the neighborhood) for each grid cell. Ignores nans by default. + + field = a numpy array that represents some surface of interest + mask = a numpy array of 0s and NaNs used to mask results -- can repeat var + input here if no mask exists + kSize = kernel size (e.g., 3 means a 3*3 kernel centered on each grid cell) + nanthreshold = maximum ratio of nan cells to total cells in the kernel for + each maximum test. 0 means that no cell with a nan neighbor can be + considered a maximum. 0.5 means that less than half of the cells in the + kernel can be nans for a cell to be considered a maximum. 1 means + that a cell will be considered a maximum even if all cells around it + are nan. Warning: since the center cell of the kernel is included in + the ratio, if you want to make decisions based on the ratio of nerighbors + that exceed some threshold, scale your desired threshold as such: + nanthreshold = desiredthreshold * (kSize*kSize-1)/(kSize*kSize). + ''' + # Find percentage of NaNs in each neighborhood + nancount = ndimage.uniform_filter(np.isnan(mask).astype(float),kSize) + + # Find the local minima + output = ndimage.maximum_filter(var,kSize) + + # export valid locations as 1s and invalid locations as 0s + return (output == var) & (nancount < nanthreshold) & np.isfinite(mask) + +'''########################### +Find Special Types of Minima of a Surface +###########################''' +def findCenters(field, mask, kSize=3, nanthreshold=0.5, d_slp=0, d_dist=100, yDist=0, xDist=0): + '''This function identifies minima in a field and then eliminates minima + that do not satisfy a gradient parameter. Also returns a list of the gradients + for maintained minima. + + field = the numpy array that you want to find the minima of.\n + kSize = the kernel size that should be considered + when determining whether it is a minimum; 3 by default\n + nanthreshold = maximum ratio of nan cells to total cells around the center + cell for each minimum test. 0 means that no cell with a nan neighbor + can be considered a minimum. 0.5 means that less than half of the + neighbors can be nans for a cell to be considered a minimum. 1 means + that a cell will be considered a minimum if all cells around it are nan.\n + d_slp and d_dist = the SLP and distance that determine the minimum pressure + gradient allowed for a minimum to be considered a cyclone center. By + default they are left at 0, so no gradients will be considered.\n + yDist, xDist = numpy arrays of distances between rows and columns. Only + necessary if calculating gradients, so the default is 0.\n + ''' + # STEP 1.1. Identify Minima: + fieldMinima = detectMinima(field,mask,kSize,nanthreshold=nanthreshold).astype(np.uint8) + + # STEP 1.2. Discard Weak Minima: + rowMin, colMin = np.where(fieldMinima == 1) # Identify locations of minima + fieldCenters = fieldMinima.copy() # make a mutable copy of the minima locations + + d_grad = float(d_slp)/d_dist # Define gradient limit + gradients = [] # Empty list to store gradient values + for sm in range(len(rowMin)): # For each minimum... + # Calculate gradient: + mean_gradient = kernelGradient(field+mask,(rowMin[sm],colMin[sm]),yDist,xDist,d_dist) +# print(str(sm) +": mean_gradient: " + str(mean_gradient) + ", d_grad: " + str(d_grad)) + # Test for pressure gradient: + if (mean_gradient < d_grad): + fieldCenters[rowMin[sm],colMin[sm]] = 0 + else: + gradients.append(mean_gradient) + + return fieldCenters, gradients + +'''########################### +Identify the Areas Associated with Special Minima of a Surface +###########################''' +def findAreas(field,fieldCenters,centers,contint,mcctol,mccdist,lats,lons,maxes=0): + '''This function actually does more than define the areas of influence for + individual cyclone centers -- it identifies the areas of influence for + entire storm systems, which means it includes the detection of multi-center + cyclones(MCCs). The basic idea is that a cyclone's area is defined by the + isobar that surrounds only that cyclone and no SLP maxima. Two minima can + be part of the same cyclone if a) the ratio of their shared area size to the + size of the unshared area of the primary (lower pressure) minimum exceeds + the mcctol parameter and b) they are within mccdist from each other. + + field = the numpy array that you want to find the minima of.\n + fieldCenters = field of cyclone center locations.\n + centers = list of cyclone center objects.\n + contint = contour interval used to define area.\n + mcctol = the maximum ratio permitted between the unshared and total area in + a multi-centered cyclone. "Unshared" area includes only the primary center. + "Shared" area is includes both the primary and secondary centers.\n + mccdist = the maximum distance two minima can lie apart and still be + considered part of the same cyclone system.\n + lats, lons = numpy arrays of latitude and longitude for the field.\n + maxes = numpy array of field maxima; optional and set to 0 by default. + ''' + if isinstance(maxes, int) == 1: + field_max = np.zeros_like(field) + else: + field_max = maxes + + # Prepare preliminary outputs: + cycField = np.zeros_like(fieldCenters) # set up empty system field + fieldCenters2 = fieldCenters.copy() # set up a field to modify center identification + # Make cyclone objects for all centers + cols, rows, vals, las, los = [], [], [], [], [] + cyclones = copy.deepcopy(centers) + types = np.zeros_like(np.array(centers)) + + # And helper lists for area detection: + for center in centers: + vals.append(center.p_cent) + rows.append(center.y) + cols.append(center.x) + las.append(center.lat) + los.append(center.lon) + + # Create list of ids and of ids that have not been assigned yet + ids = np.arange(len(vals)) + candids = np.where(types == 0)[0] + + ##################### + # Start Area Loop + ##################### + while len([t for t in types if t == 0]) > 0: + # Identify the center ID as the index of the lowest possible value + ## that hasn't already been assigned to a cyclone: + cid = ids[np.where((types == 0) & (np.array(vals) == np.min(np.array(vals)[np.where(types == 0)[0]])))][0] + + nMins = 0 # set flag to 0 + nMaxs = 0 # set flag to 0 + cCI = vals[cid] # set the initial contouring value + + # Identify the number of minima within the mccdist + distTest = [haversine(las[cid],las[i],los[cid],los[i]) > mccdist for i in candids] + ncands = len(distTest) - np.sum(distTest) # Number of centers w/n the mccdist + icands = candids[np.where(np.array(distTest) == 0)] # IDs for centers w/n mccdist + + ######################### + # If No MCC Is Possible # + ######################### + # If there's no other minima w/n the mccdist, use the simple method + if ncands == 1: + while nMins == 0 and nMaxs == 0 and cCI < np.nanmax(field): # keep increasing interval as long as only one minimum is detected + #Increase contour interval + cCI = cCI + contint + + # Define contiguous areas + fieldCI = np.where(field < cCI, 1, 0) + areas, nA = ndimage.measurements.label(fieldCI) + + # Test how many minima are within the area associated with the minimum of interest - Limit to minima within that area + nMins = np.sum( np.where((areas == areas[rows[cid],cols[cid]]) & (fieldCenters > 0), 1, 0) ) - 1 # Count the number of minima identified (besides the minimum of focus) + + # Test how many maxima are within the area associated with the minimum of interest - Limit to maxima within that area + nMaxs = np.sum( np.where((areas == areas[rows[cid],cols[cid]]) & (field_max == 1), 1, 0) ) # Count the number of maxima identified + + # Re-adjust the highest contour interval + cCI = cCI - contint + + ###################### + # If MCC Is Possible # + ###################### + else: + cCIs, aids = [], [] + while nMins == 0 and nMaxs == 0 and cCI < np.nanmax(field): # keep increasing interval as long as only one minimum is detected + #Increase contour interval + cCI = cCI + contint + + # Define contiguous areas + fieldCI = np.where(field < cCI, 1, 0) + areas, nA = ndimage.measurements.label(fieldCI) + + # Test how many minima are within the area associated with the minimum of interest + areaTest = np.where((areas == areas[rows[cid],cols[cid]]) & (fieldCenters > 0)) # Limit to minima within that area + + # Test how many maxima are within the area associated with the minimum of interest - Limit to maxima within that area + nMaxs = np.sum( np.where((areas == areas[rows[cid],cols[cid]]) & (field_max == 1), 1, 0) ) # Count the number of maxima identified + + # Record the area and the ids of the minima encircled for each contint + locSub = [(areaTest[0][ls],areaTest[1][ls]) for ls in range(areaTest[0].shape[0])] + idsSub = [i for i in ids if ((rows[i],cols[i]) in locSub) & (i != cid)] + cCIs.append(cCI) + aids.append(idsSub) + # Record the number of minima w/n the area that are outside the mccdist + nMins = len(aids[-1]) - np.sum([i in icands for i in aids[-1]]) + + # If there are possible secondaries within mccdist, evaluate MCC possibilities + # Also, only check if its possible for the number of contour intervals to exceed mcctol + if (len(aids) > 1) and ( len(aids[-1]) > 1 or (len(aids[-1]) == 1 and nMins == 0) ): + # For each minimum in the last aids position before breaking, make a contour interval test, + aids, cCIs = aids[:-1], cCIs[:-1] + ## Starting with the last center to be added (fewest instances w/n the area) + nCIs = [np.sum([i in ii for ii in aids]) for i in aids[-1]] + breaker = 0 + + while breaker == 0 and len(nCIs) > 0: + ai = aids[-1][np.where(np.array(nCIs) == np.min(nCIs))[0][0]] # Find id + ai0 = np.where(np.array([ai in i for i in aids]) == 1)[0][0] # First contour WITH secondary + + numC1 = (cCIs[ai0] - contint - vals[cid]) # The number of contour intervals WITHOUT secondaries + numC2 = (cCIs[-1] - vals[cid]) # The number of contour intervals WITH secondaries + + # If including secondaries substantially increases the number of contours involved... + if (numC1 / numC2) < mcctol: + for i in aids[-1]: # Add all of the other minima at this level as secondaries + cyclones[i].add_parent(rows[cid],cols[cid],cid) + fieldCenters2[rows[i],cols[i]] = 2 + cyclones[cid].secondary.append(centers[i].id) + cyclones[i].type = 2 + types[i] = 2 # And change the type to secondary so it will no longer be considered + + # Force the loop to end; all remaining minima must also be secondaries + breaker = 1 + + else: + cCIs = cCIs[:ai0] + aids = aids[:ai0] + nCIs = [np.sum([i in ii for ii in aids]) for i in aids[-1]] + + # Once secondaries are accoutned for, re-establish the highest contour + cCI = cCIs[-1] + + # Otherwise, ignore such possibilities + else: + cCI = cCI - contint + + ######################### + # Final Area Assignment # + ######################### + # Assign final contiguous areas: + fieldF = np.where(field < (cCI), 1, 0) + areasF, nAF = ndimage.measurements.label(fieldF) + + # And identify the area associated with the minimum of interest: + area = np.where((areasF == areasF[rows[cid],cols[cid]]) & (areasF != 0),1,0) + if np.nansum(area) == 0: # If there's no area already, + area[rows[cid],cols[cid]] = 1 # Give it an area of 1 to match the location of the center + + cycField = cycField + area # And update the system field + + # Then assign characteristics to the cyclone: + cyclones[cid].type = 1 + cyclones[cid].p_edge = cCI + cyclones[cid].area = np.nansum(area) + + # Also assign those characteristics to its secondaries: + for cid_s in cyclones[cid].secondary: + cyclones[cid_s].p_edge = cCI + cyclones[cid_s].area = np.nansum(area) + + # When complete, change type of cyclone and re-set secondary candidates + types[cid] = 1 + candids = np.where(types == 0)[0] + #print("ID: " + str(cid) + ", Row: " + str(rows[cid]) + ", Col: " + str(cols[cid]) + \ + # ", Area:" + str(np.nansum(area))) + + return cycField.astype(np.uint8), fieldCenters2, cyclones + +'''########################### +Calculate Cyclone Associated Precipitation +###########################''' +def findCAP(cyclones,areas,plsc,ptot,yDist,xDist,lats,lons,pMin=0.375,r=250000): + '''Calculates the cyclone-associated precipitation for each cyclone in + a cyclone field object. Cyclones must have a corresponding area array + as an input. Input precipitation fields must have the same + projection and grid cell size as the cyclone field.\n + + Required inputs:\n + cyclones = list of cyclone objects for the instant in time under consideration. + Typically, this will be a subset of cyclones (e.g., only primary cyclone + centers) from a cyclonefield object + areas = an array of 1s and 0s, where 1 indicates grid cells within the area of a cyclone\n + (Note that both cyclones and areas can be found in a cyclonefield object)\n + plsc = large-scale precipitation field + ptot = total precipitation field + yDist, xDist = numpy arrays of distances between rows and columns. Only + necessary if calculating gradients, so the default is 0.\n + lats, lons = numpy arrays of latitude and longitude for the field.\n + cellarea = area of a grid cell (km^2) + pMin = the minimum amount of large-scale precipitation required for + defining contiguous precipitation areas -- the default of 0.375 is + appropriate for precipation data with units of mm at a 6-h interval. + This value should be divided by 2 if using 3-h instead of 6-h; it + should be divided by 1000 if using m instead of mm. + r = radius defining minimum area around which to search for precipitation + (this is in addition to any area defined as part of the cyclone by the + algorithm) -- must be same units as grid cell size + + Returns a field of CAP, but also updates the precipitation values for each + cyclone center in the cyclone field object. + ''' + ############# + # PREP WORK # + ############# + # Eliminate Non-Finite values + ptot, plsc = np.where(np.isfinite(ptot) == 1,ptot,0), np.where(np.isfinite(plsc) == 1,plsc,0) + + # Add edges to the precipitation rasters + cR, cC = areas.shape[0], areas.shape[1] + pR, pC = plsc.shape[0], plsc.shape[1] + + plsc = np.hstack(( np.zeros( (cR,int((cC-pC)/2)) ) , \ + np.vstack(( np.zeros( (int((cR-pR)/2),pC) ), plsc ,np.zeros( (int((cR-pR)/2),pC) ) )), \ + np.zeros( (cR,int((cC-pC)/2)) ) )) + + ptot = np.hstack(( np.zeros( (cR,int((cC-pC)/2)) ) , \ + np.vstack(( np.zeros( (int((cR-pR)/2),pC) ), ptot ,np.zeros( (int((cR-pR)/2),pC) ) )), \ + np.zeros( (cR,int((cC-pC)/2)) ) )) + + # Identify large-scale precipitation regions + pMasked = np.where(plsc >= pMin, 1, 0) + pAreas, nP = ndimage.measurements.label(pMasked) + cAreas, nC = ndimage.measurements.label(areas) + aIDs = [c.areaID for c in cyclones] + + # Identify cyclone ids + ids = np.array(range(len(cyclones))) + + # Create empty lists/arrays + cInt = [[] for p in range(nP+1)]# To store the cyc ID for each precip region + cPrecip = np.zeros((len(ids))) # To store the total precip for each cyclone center + cPrecipArea = np.zeros((len(ids))) # To store the precip area for each cyclone center + + ###################### + # FIND INTERSECTIONS # + ###################### + for i in ids: # For each center, + # Identify corresponding area + c = cyclones[i] + cArea = np.where(cAreas == aIDs[i],1,0) # Calc'd area + + # number of cells in either cardinal direction of the center of the kernel + k = np.int( np.ceil( r / min(yDist[c.y,c.x],xDist[c.y,c.x]) ) ) + # number of cells in each row and column of the kernel + kdiameter = k*2+1 + + # for each cell, calculate x distances and y distances from the center + kxdists = np.tile(np.arange(-k, k + 1), (kdiameter, 1)) * xDist[c.y,c.x] + kydists = np.rot90(np.tile(np.arange(-k, k + 1), (kdiameter, 1)) * yDist[c.y,c.x]) + + # Assign True/False based on distance from center + kernel = np.sqrt( np.square(kxdists) + np.square(kydists) ) <= r + + # Modify cyclone area + cArea[c.y-k:c.y+k+1,c.x-k:c.x+k+1] = \ + np.where(cArea[c.y-k:c.y+k+1,c.x-k:c.x+k+1] + kernel != 0, 1, 0) # Add a radius-based area + + # Find the intersecting precip areas + pInt = np.unique(cArea*pAreas)[np.where(np.unique(cArea*pAreas) != 0)] + + # Assign cyc id to intersecting precip areas + for pid in pInt: + cInt[pid].append(i) + + # Identify unique intersecting precip areas + pList = [p for p in range(nP+1) if len(cInt[p]) != 0] + + #################### + # PARTITION PRECIP # + #################### + # For each intersecting precip area, + for p in pList: + # If it only intersects 1 center... + if len(cInt[p]) == 1: + # Assign all TOTAL precip to the cyclone + pArea = np.where(pAreas == p,1,0) + cPrecip[cInt[p][0]] += np.sum(ptot*pArea) + cPrecipArea[cInt[p][0]] += np.sum(pArea) + + # If more than one cyclone center intersects, + else: # Assign each grid cell to the closest cyclone area + # Identify coordinates for this precipitation area + pcoords = np.array(np.where(pAreas == p)).T + + # Identify the area indices + aInt = [aIDs[a] for a in cInt[p]] + + # Find the minimum distance between each point in the precipitation area + # and any point in each intersecting cyclone area + distances = [] + for ai in aInt: # Looping through each cyclone area + acoords = np.array(np.where(cAreas == ai)).T # Identifying coordinates for the cyclone area + distances.append( cdist(pcoords,acoords).min(axis=1) ) # Identifying the minimum distance from each precip area coordinate + + # Identify which cyclone area contained the shortest distance for each point within the precip area + closest_index = np.array(distances).argmin(axis=0) + + # Assign TOTAL precip to the closest cyclone + for i,ai in enumerate(aInt): + ci = cInt[p][np.where(np.array(aInt) == ai)[0][0]] # Cyclone index associated with that area index + + pcoords2 = pcoords[closest_index == i] # Subset to just the precip area coordinate for which this cyclone's area is closest + + cPrecip[ci] += ptot[pcoords2[:,0],pcoords2[:,1]].sum() # Sum all precip + cPrecipArea[ci] += pcoords2.shape[0] # Add all points + + ################## + # RECORD PRECIP # + ################# + # Final assignment of precip to primary cyclones + for i in ids: + cyclones[i].precip = cPrecip[i] + cyclones[i].precipArea = cPrecipArea[i] + + # Return CAP field + return ptot*np.in1d(pAreas,np.array(pList)).reshape(pAreas.shape) + + +def findCAP2(cyclones,areas,plsc,ptot,yDist,xDist,lats,lons,pMin=0.375,r=250000): + '''Calculates the cyclone-associated precipitation for each cyclone in + a cyclone field object. Cyclones must have a corresponding area array + as an input. Input precipitation fields must have the same + projection and grid cell size as the cyclone field.\n + + Returns more outputs than the basic findCAP function but also takes longer to run. + + Required inputs:\n + cyclones = list of cyclone objects for the instant in time under consideration. + Typically, this will be a subset of cyclones (e.g., only primary cyclone + centers) from a cyclonefield object + areas = an array of 1s and 0s, where 1 indicates grid cells within the area of a cyclone\n + (Note that both cyclones and areas can be found in a cyclonefield object)\n + plsc = large-scale precipitation field + ptot = total precipitation field + yDist, xDist = numpy arrays of distances between rows and columns. Only + necessary if calculating gradients, so the default is 0.\n + lats, lons = numpy arrays of latitude and longitude for the field.\n + cellarea = area of a grid cell (km^2) + pMin = the minimum amount of large-scale precipitation required for + defining contiguous precipitation areas -- the default of 0.375 is + appropriate for precipation data with units of mm at a 6-h interval. + This value should be divided by 2 if using 3-h instead of 6-h; it + should be divided by 1000 if using m instead of mm. + r = radius defining minimum area around which to search for precipitation + (this is in addition to any area defined as part of the cyclone by the + algorithm) -- must be same units as grid cell size + + Returns a field of CAP and a field of cyclone IDs for each grid cell with CAP. + Also updates the precipitation values for each cyclone center in the cyclone + field object. + ''' + ############# + # PREP WORK # + ############# + # Eliminate Non-Finite values + ptot[np.isfinite(ptot) == 0], plsc[np.isfinite(plsc) == 0] = 0, 0 + + # Add edges to the precipitation rasters (if different sizes than the area array) + cR, cC = areas.shape[0], areas.shape[1] + pR, pC = plsc.shape[0], plsc.shape[1] + + plsc = np.hstack(( np.zeros( (cR,int((cC-pC)/2)) ) , \ + np.vstack(( np.zeros( (int((cR-pR)/2),pC) ), plsc ,np.zeros( (int((cR-pR)/2),pC) ) )), \ + np.zeros( (cR,int((cC-pC)/2)) ) )) + + ptot = np.hstack(( np.zeros( (cR,int((cC-pC)/2)) ) , \ + np.vstack(( np.zeros( (int((cR-pR)/2),pC) ), ptot ,np.zeros( (int((cR-pR)/2),pC) ) )), \ + np.zeros( (cR,int((cC-pC)/2)) ) )) + + # Identify large-scale precipitation regions + pMasked = np.where(plsc >= pMin, 1, 0) + pAreas, nP = ndimage.measurements.label(pMasked) + cAreas, nC = ndimage.measurements.label(areas) + aIDs = [c.areaID for c in cyclones] + + # Identify cyclone ids + ids = np.array(range(len(cyclones))) + + # Create empty lists/arrays + cInt = [[] for p in range(nP+1)]# To store the cyc ID for each precip region + cPrecip = np.zeros((len(ids))) # To store the total precip for each cyclone center + cPrecipArea = np.zeros((len(ids))) # To store the precip area for each cyclone center + + ###################### + # FIND INTERSECTIONS # + ###################### + for i in ids: # For each center, + # Identify corresponding area + c = cyclones[i] + cArea = np.where(cAreas == aIDs[i],1,0) # Calc'd area + + # number of cells in either cardinal direction of the center of the kernel + k = np.int( np.ceil( r / min(yDist[c.y,c.x],xDist[c.y,c.x]) ) ) + # number of cells in each row and column of the kernel + kdiameter = k*2+1 + + # for each cell, calculate x distances and y distances from the center + kxdists = np.tile(np.arange(-k, k + 1), (kdiameter, 1)) * xDist[c.y,c.x] + kydists = np.rot90(np.tile(np.arange(-k, k + 1), (kdiameter, 1)) * yDist[c.y,c.x]) + + # Assign True/False based on distance from center + kernel = np.sqrt( np.square(kxdists) + np.square(kydists) ) <= r + + # Modify cyclone area + cArea[c.y-k:c.y+k+1,c.x-k:c.x+k+1] = \ + np.where(cArea[c.y-k:c.y+k+1,c.x-k:c.x+k+1] + kernel != 0, 1, 0) # Add a radius-based area + + # Find the intersecting precip areas + pInt = np.unique(cArea*pAreas)[np.where(np.unique(cArea*pAreas) != 0)] + + # Assign cyc id to intersecting precip areas + for pid in pInt: + cInt[pid].append(i) + + # Identify unique intersecting precip areas + pList = [p for p in range(nP+1) if len(cInt[p]) != 0] + + # Create an output array that will be filled with the cyclone id to which the precipitation is assigned + pArea2 = np.in1d(pAreas,np.array(pList)).reshape(pAreas.shape).astype(float) + pArea2[pArea2 == 0] = np.nan + + #################### + # PARTITION PRECIP # + #################### + # For each intersecting precip area, + for p in pList: + # If it only intersects 1 center... + if len(cInt[p]) == 1: + # Assign all TOTAL precip to the cyclone + pArea = np.where(pAreas == p,1,0) + cPrecip[cInt[p][0]] += np.sum(ptot*pArea) + cPrecipArea[cInt[p][0]] += np.sum(pArea) + + # Assign cyclone id to precip area + pArea2[pAreas == p] = cInt[p][0] + + # If more than one cyclone center intersects, + else: # Assign each grid cell to the closest cyclone area + # Identify coordinates for this precipitation area + pcoords = np.array(np.where(pAreas == p)).T + + # Identify the area indices + aInt = [aIDs[a] for a in cInt[p]] + + # Find the minimum distance between each point in the precipitation area + # and any point in each intersecting cyclone area + distances = [] + for ai in aInt: # Looping through each cyclone area + acoords = np.array(np.where(cAreas == ai)).T # Identifying coordinates for the cyclone area + distances.append( cdist(pcoords,acoords).min(axis=1) ) # Identifying the minimum distance from each precip area coordinate + + # Identify which cyclone area contained the shortest distance for each point within the precip area + closest_index = np.array(distances).argmin(axis=0) + + # Assign TOTAL precip to the closest cyclone + for i,ai in enumerate(aInt): + ci = cInt[p][np.where(np.array(aInt) == ai)[0][0]] # Cyclone index associated with that area index + + pcoords2 = pcoords[closest_index == i] # Subset to just the precip area coordinate for which this cyclone's area is closest + + cPrecip[ci] += ptot[pcoords2[:,0],pcoords2[:,1]].sum() # Sum all precip + cPrecipArea[ci] += pcoords2.shape[0] # Add all points + + # Assign cyclone id to precip area + pArea2[pcoords2[:,0],pcoords2[:,1]] = ci + + ################## + # RECORD PRECIP # + ################# + # Final assignment of precip to primary cyclones + for i in ids: + cyclones[i].precip = cPrecip[i] + cyclones[i].precipArea = cPrecipArea[i] + + # Return CAP field + return ptot*np.isfinite(pArea2), pArea2 + +'''########################### +Nullify Cyclone-Related Data in a Cyclone Center Track Instance (not Track-Related Data) +###########################''' +def nullifyCycloneTrackInstance(ctrack,time,ptid): + '''This function operates on a cyclonetrack object. Given a particular + time, it will turn any row with the time in the main data frame + (ctrack.data) into a partially nullified row. Some values are left + unchanged (id, mcc, time, x, y, u, and v, and the event flags). Some values + (area, centers, radius, type) will be set to 0. The cyclone-specifc values + are set to np.nan. + + The reason for having this function is that sometimes it's desirable to + track a center's position during a merge or split, but it's not appropriate + to assign other characteristics. + + ctrack = a cyclonetrack object + time = a time step (float) corresponding to a row in ctrack (usu. in days) + ptid = the track into which it's merging + ''' + ctrack.data.loc[ctrack.data.time == time, "area"] = 0 + ctrack.data.loc[ctrack.data.time == time, "centers"] = 0 + ctrack.data.loc[ctrack.data.time == time, "radius"] = 0 + ctrack.data.loc[ctrack.data.time == time, "type"] = 0 + + ctrack.data.loc[ctrack.data.time == time, "DpDt"] = np.nan + ctrack.data.loc[ctrack.data.time == time, "DsqP"] = np.nan + ctrack.data.loc[ctrack.data.time == time, "depth"] = np.nan + ctrack.data.loc[ctrack.data.time == time, "p_cent"] = np.nan + ctrack.data.loc[ctrack.data.time == time, "p_edge"] = np.nan + + ctrack.data.loc[ctrack.data.time == time,"ptid"] = ptid + + return ctrack + +'''########################### +Initialize Cyclone Center Tracks +###########################''' +def startTracks(cyclones): + '''This function initializes a set of cyclone tracks from a common start date + when given a list of cyclone objects. It it a necessary first step to tracking + cyclones. Returns a list of cyclonetrack objects and an update list of cyclone + objects with track ids. + + cyclones = a list of objects of the class minimum. + ''' + ct = [] + for c,cyc in enumerate(cyclones): + # Define Etype + if cyc.type == 2: + Etype = 1 + ptid = cyc.parent["id"] + else: + Etype = 3 + ptid = c + + # Create Track + ct.append(cyclonetrack(cyc,c,Etype,ptid)) + + # Assign IDs to cyclone objects + cyc.tid = c + + return ct, cyclones + +'''########################### +Track Cyclone Centers Between Two Time Steps +###########################''' +def trackCyclones(cfa,cfb,ctr,maxdist,red,tmstep): + '''This function tracks cyclone centers and areas between two times when + given the cyclone fields for those two times, a cyclone track object that + is updated through time 1, maximum cyclone propagation distance (meters), + a speed reduction parameter, and the time interval (hr). The result is an + updated cyclone track object and updated cyclone field object for time 2. + + Steps in function: + 1. Main Center Tracking + 1.1. Center Lysis + 1.2. Center Tracking + 2. Center Merges + 3. Centers Splits and Genesis + 2.2. Center Splits + 2.3. Center Genesis + 4. Area Merges & Splits (MCCs) + 4.1. Area Merges + 4.2. Special Area Lysis (Re-Genesis) + 4.3. Area Splits + 5. Clean-Up for MCCs + ''' + # First make copies of the cyclonetrack and cyclonefield inputs to ensure that nothing is overwritten + ct = copy.deepcopy(ctr) + cf1 = copy.deepcopy(cfa) + cf2 = copy.deepcopy(cfb) + + # Calculate the maximum number of cells a cyclone can move + time1 = cf1.time + time2 = cf2.time + + # Create helper lists: + y2s = cf2.lats() + x2s = cf2.lons() + mc2 = [[] for i in y2s] # stores ids of cf1 centers that map to each cf2 center + sc2 = [[] for i in y2s] # stores ids of cf1 centers that were within distance to map but chose a closer cf2 center + sc2dist = [[] for i in y2s] # stores the distance between the cf1 centers and rejected cf2 centers + + y1s, x1s = cf1.lats(), cf1.lons() # store the locations from time 1 + p1s = list(np.array(cf1.p_edge()) - np.array(cf1.p_cent())) # store the depth from time 1 + + ################################ + # PART 1. MAIN CENTER TRACKING # + ################################ + # Loop through each center in cf1 to find matches in cf2 + for c,cyc in enumerate(cf1.cyclones): + cyct = ct[cyc.tid] # Link the cyclone instance to its track + + # Create a first guess for the next location of the cyclone: + if len(cyct.data) == 1: # If cf1 represents the genesis event, first guess is no movement + latq = cyc.lat + lonq = cyc.lon + elif len(cyct.data) > 1: # If the cyclone has moved in the past, a linear projection of its movement is the best guess + latq = addLat(cyc.lat,dist2lat(float(red*cyct.data.loc[cyct.data.time == cyc.time,"v"])*tmstep)) + lonq = addLon(cyc.lon,dist2lon(float(red*cyct.data.loc[cyct.data.time == cyc.time,"u"])*tmstep,cyc.lat,cyc.lat)) + + # Test every point in cf2 to see if it's within distance d of both (yq,xq) AND (y,x) + pdqs = [haversine(y2s[p],latq,x2s[p],lonq) for p in range(len(y2s))] + pds = [haversine(y2s[p],cyc.lat,x2s[p],cyc.lon) for p in range(len(y2s))] + + pds_n = sum([((pds[p] <= maxdist) and (pdqs[p] <= maxdist)) for p in range(len(pdqs))]) + + ########################## + # PART 1.1. CENTER LYSIS # + # If no point fits the criterion, then the cyclone experienced lysis + if pds_n == 0: + # Add a nullified instance for time2 at the same location as time 1 + cyc_copy = copy.deepcopy(cyc) + cyc_copy.time = time2 + cyct.addInstance(cyc_copy) + cyct = nullifyCycloneTrackInstance(cyct,time2,cyc.tid) + # Add a lysis event + cyct.addEvent(cyc,time2,"ly",3) + + ############################## + # PART 1.2. CENTER TRACKING # + # If one or more points fit the criterion, select the nearest neighbor to the projection + else: + # Take the one closest to the projected point (yq xq) + c2 = np.where(np.array(pdqs) == min(pdqs))[0][0] + cyct.addInstance(cf2.cyclones[c2]) + cf2.cyclones[c2].tid = cyc.tid # link the two cyclone centers with a track id + mc2[c2].append(c) # append cf1 id to the merge list + + # Remove that cf2 cyclone from consideration + pds[c2] = maxdist+1 + pds_n = pds_n - 1 + + # Add the cf1 center to the splits list for the remaining cf2 centers that fit the dist criteria + while pds_n > 0: + s2 = np.where(np.array(pds) == min(pds))[0][0] + sc2[s2].append(c) + sc2dist[s2].append(min(pds)) + pds[s2] = maxdist+1 + pds_n = pds_n -1 + + ########################## + # PART 2. CENTER MERGES # + ######################### + # First, remove the split possibility for any time 2 center that is continued + for id2 in range(len(sc2)): + if (len(sc2[id2]) > 0) and (len(mc2[id2]) > 0): + sc2[id2], sc2dist[id2] = [], [] + + # Check for center merges (mc) and center splits (sc) + for id2 in range(len(mc2)): + # If there is only one entry in the merge list, then it's a simple tracking; nothing else required + # But if multiple cyclones from cf1 match the same cyclone from cf2 -> CENTER MERGE + if len(mc2[id2]) > 1: + ### DETERMINE PRIMARY TRACK ### + dist1_mc2 = [haversine(y1s[i],y2s[id2],x1s[i],x2s[id2]) for i in mc2[id2]] + p1s_mc2 = [p1s[i] for i in mc2[id2]] + tid_mc2 = [cf1.cyclones[i].tid for i in mc2[id2]] + + # Select the closer, then deeper storm for continuation + + # Which is closest? + dist1min = np.where(np.array(dist1_mc2) == min(dist1_mc2))[0] + if len(dist1min) == 1: # if one center is closest + id1 = mc2[id2][dist1min[0]] # find the id of the closest + # Which center is deepest? + else: # if multiple centers are same distance, choose the greater depth + id1 = mc2[id2][np.where(np.array(p1s_mc2) == max(p1s_mc2))[0][0]] # find id of the max cf1 depth + # Note that if two cyclones have the same distance & depth, the first by id is automatically taken. + + # Check if ptid of id1 center is the tid of another merge candidate + ptid1 = int(ct[cf1.cyclones[id1].tid].data.loc[ct[cf1.cyclones[id1].tid].data.time == cf1.time,"ptid"]) + ptid_test = [ptid1 == i and i != cf1.cyclones[id1].tid for i in tid_mc2] + if sum(ptid_test) > 0: # if the ptid is the tid of one of the other candidates, merge into the parent instead + id1 = int(ct[ptid1].data.loc[ct[ptid1].data.time == time1,"id"]) + + # Assign the primary track tid + cf2.cyclones[id2].tid = cf1.cyclones[id1].tid + + ### DEAL WITH NON-CONTINUED CENTERS ### + c1extra = copy.deepcopy(mc2[id2]) + c1extra.remove(id1) + + for c1id in c1extra: + # First check if this center could have continued to another center in time 2 + sptest = [c1id in sc for sc in sc2] + # If yes, then change the track assignment + if np.sum(sptest) > 0: + # Find the new closest center + sc2s = np.where(np.array(sptest) == 1)[0] # The ids for the candidate time 2 centers + sc2dists = [] + for sc2id in sc2s: + sc2dists.append( sc2dist[sc2id][np.where(np.array(sc2[sc2id]) == c1id)[0][0]] ) + + c2id = sc2s[np.where(np.array(sc2dists) == np.min(sc2dists))][0] # New time 2 id for continuation of the time 1 track + + # Remove the original instance established in Part 1.2. + ct[cf1.cyclones[c1id].tid].removeInstance(time2) + + # And add a new instance + ct[cf1.cyclones[c1id].tid].addInstance(cf2.cyclones[c2id]) + cf2.cyclones[c2id].tid = cf1.cyclones[c1id].tid # link the two cyclone centers with a track id + mc2[c2id].append(c1id) # append cf1 id to the merge list + mc2[id2].remove(c1id) # remove from its former location + sc2[c2id] = [] # clear the corresponding element of the split list + + # Otherwise, assign a merge event with the primary track + else: + # If the two centers shared the same parent in time 1 (they were an mcc), it's just a center merge + if cf1.cyclones[c1id].parent["id"] == cf1.cyclones[id1].parent["id"]: + # Add merge event to primary track + ct[cf1.cyclones[id1].tid].addEvent(cf2.cyclones[id2],time2,"mg",1,otid=cf1.cyclones[c1id].tid) + + # Add merge event to non-continued (secondary) track + ct[cf1.cyclones[c1id].tid].addEvent(cf2.cyclones[id2],time2,"mg",1,otid=cf1.cyclones[id1].tid) + ct[cf1.cyclones[c1id].tid].addEvent(cf2.cyclones[id2],time2,"ly",1) # Add a lysis event + # Set stats to zero for current time since I really just want the (y,x) location + ct[cf1.cyclones[c1id].tid] = nullifyCycloneTrackInstance(ct[cf1.cyclones[c1id].tid],time2,cf1.cyclones[id1].tid) + + # If they had different parents, it's both a center merge and an area merge + else: + # Add merge event o primary track + ct[cf1.cyclones[id1].tid].addEvent(cf2.cyclones[id2],time2,"mg",3,otid=cf1.cyclones[c1id].tid) + + # Add merge event to non-continued (secondary) track + ct[cf1.cyclones[c1id].tid].addEvent(cf2.cyclones[id2],time2,"mg",3,otid=cf1.cyclones[id1].tid) + ct[cf1.cyclones[c1id].tid].addEvent(cf2.cyclones[id2],time2,"ly",3) # Add a lysis event + # Set stats to zero for current time since I really just want the (y,x) location + ct[cf1.cyclones[c1id].tid] = nullifyCycloneTrackInstance(ct[cf1.cyclones[c1id].tid],time2,cf1.cyclones[id1].tid) + + ##################################### + # PART 3. CENTER SPLITS AND GENESIS # + ##################################### + for id2 in range(len(sc2)): + ########################### + # PART 3.1. CENTER SPLITS # + # If no cyclones from cf1 match a particular cf2 cyclone, it's either a center split or a pure genesis event + if len(mc2[id2]) == 0 and len(sc2[id2]) > 0: # if there's one or more centers that could have tracked there -> SPLIT + ### DETERMINE SOURCE CENTER ### + # Make the split point the closest center of the candidate(s) + dist_sc2 = [haversine(cf1.cyclones[i].lat,cf2.cyclones[id2].lat,cf1.cyclones[i].lon,cf2.cyclones[id2].lon) for i in sc2[id2]] + id1= sc2[id2][np.where(np.array(dist_sc2) == min(dist_sc2))[0][0]] + + # Start the new track a time step earlier + cf2.cyclones[id2].tid = len(ct) # Assign a new track id to the cf2 center + ct.append(cyclonetrack(cf1.cyclones[id1],tid=cf2.cyclones[id2].tid)) # Make a new track with that id + # Set stats to zero since I really just want the (y,x) location + ct[cf2.cyclones[id2].tid] = nullifyCycloneTrackInstance(ct[cf2.cyclones[id2].tid],time1,cf1.cyclones[id1].tid) + + # Add an instance for the current time step (cf2) + ct[cf2.cyclones[id2].tid].addInstance(cf2.cyclones[id2]) + # Adjust when the genesis is recorded + ct[cf2.cyclones[id2].tid].events.time = time2 + ct[cf2.cyclones[id2].tid].data.loc[ct[cf2.cyclones[id2].tid].data.time == time1,"Ege"] = 0 + ct[cf2.cyclones[id2].tid].data.loc[ct[cf2.cyclones[id2].tid].data.time == time2,"Ege"] = 3 + + ### ASSIGN SPLIT EVENTS ### + # Find the id of the other branch of the split in the time of cf2 + id2_1 = int(ct[cf1.cyclones[id1].tid].data.loc[ct[cf1.cyclones[id1].tid].data.time == time2,"id"]) + + # Add a split event to the primary track and the new track + if cf2.cyclones[id2_1].parent["id"] == cf2.cyclones[id2].parent["id"]: + # If the two centers have the same parent in time 2, it was just a center split + ct[cf1.cyclones[id1].tid].addEvent(cf2.cyclones[id2_1],time2,"sp",1,otid=cf2.cyclones[id2].tid) + ct[cf2.cyclones[id2].tid].addEvent(cf2.cyclones[id2],time2,"sp",1,otid=cf1.cyclones[id1].tid) + ct[cf2.cyclones[id2].tid].data.loc[ct[cf2.cyclones[id2].tid].data.time == time2,"Ege"] = 1 + ct[cf2.cyclones[id2].tid].events.loc[(ct[cf2.cyclones[id2].tid].events.time == time2) & \ + (ct[cf2.cyclones[id2].tid].events.event == "ge"),"Etype"] = 1 + else: + # If they don't, then it was also an area split + ct[cf1.cyclones[id1].tid].addEvent(cf2.cyclones[id2_1],time2,"sp",3,otid=cf2.cyclones[id2].tid) + ct[cf2.cyclones[id2].tid].addEvent(cf2.cyclones[id2],time2,"sp",3,otid=cf1.cyclones[id1].tid) + # Amend the parent track id to be it's own track now that it's split + ct[cf2.cyclones[id2].tid].data.loc[ct[cf2.cyclones[id2].tid].data.time == time2,"ptid"] = cf2.cyclones[id2].tid + ct[cf2.cyclones[id2].tid].ptid = cf2.cyclones[id2].tid + # Note that this might be overwritten yet again if this center has an area merge with another center + + ############################ + # PART 3.2. CENTER GENESIS # + elif len(mc2[id2]) == 0 and len(sc2[id2]) == 0: # if there's no center that could have tracked here -> GENESIS + cf2.cyclones[id2].tid = len(ct) # Assign the track id to the cf2 center + + if cf2.cyclones[id2].centerCount() == 1: # If it's a scc, it's both an area and center genesis + ct.append(cyclonetrack(cf2.cyclones[id2],cf2.cyclones[id2].tid,3,cf2.cyclones[id2].tid)) # Make a new track + + else: # If it's a mcc, things are more complicated + # Find center ids of mcc centers + mcc_ids = cf2.cyclones[cf2.cyclones[id2].parent["id"]].secondary + mcc_ids.append(cf2.cyclones[id2].parent["id"]) + # Find which have prior tracks + prior = [( (len(mc2[mccid]) > 0) or (len(sc2[mccid]) > 0) ) for mccid in mcc_ids] + + if (sum(prior) > 0) or (cf2.cyclones[id2].type == 2): + # If it's a secondary center or if one of the centers in the mcc already has a track, + ### then it's only a center genesis + + ct.append(cyclonetrack(cf2.cyclones[id2],cf2.cyclones[id2].tid,1)) # Make a new track + else: + # If all centers in the mcc are new and this is the primary center, + ### then it's both center and area genesis + ct.append(cyclonetrack(cf2.cyclones[id2],cf2.cyclones[id2].tid,3,cf2.cyclones[id2].tid)) # Make a new track + + ####################################################### + # PART 4. AREA-ONLY SPLITS & MERGES and SPECIAL LYSIS # + ####################################################### + ######################## + # PART 4.1. AREA MERGE # + for cy2 in cf2.cyclones: + # If cy2 is part of an mcc + if int(ct[cy2.tid].data.loc[ct[cy2.tid].data.time == time2,"centers"]) != 1: + # Find the id of cy2 in time of cf1: + if len(ct[cy2.tid].data.loc[(ct[cy2.tid].data.time == time1) & (ct[cy2.tid].data.type != 0)]) == 0: + continue # gensis event, no center to compare + else: + cy2_id1 = int(ct[cy2.tid].data.loc[ct[cy2.tid].data.time == time1,"id"]) + + # Find track ids for each center in the mcc that isn't cy2 + ma_tids = [cy.tid for cy in cf2.cyclones if ((cy.parent["id"] == cy2.parent["id"]) and (cy.id != cy2.id))] + + for ti in ma_tids: # for each track id + if len(ct[ti].data.loc[ct[ti].data.time == time1,"id"]) == 0: + continue # genesis event, no center to compare + else: + ma_id1 = int(ct[ti].data.loc[ct[ti].data.time == time1,"id"] )# Find the center id for cf1 + ma_parent_id = cf1.cyclones[ma_id1].parent["id"] # Find parent id at time of cf1 + if ma_parent_id != cf1.cyclones[cy2_id1].parent["id"]: # If the two are not the same + # Assign an area merge to cy2's track + ct[cy2.tid].addEvent(cy2,time2,"mg",2,otid=ti) + + for cy1 in cf1.cyclones: + # If cy1 was part of an mcc + if int(ct[cy1.tid].data.loc[ct[cy1.tid].data.time == time1,"centers"]) != 1: + # What was the primary track in time 1? + ptid1 = int(ct[cy1.tid].data.loc[ct[cy1.tid].data.time == time1,"ptid"]) + + ########################### + # PART 4.2. SPECIAL LYSIS # + if len(ct[cy1.tid].data.loc[(ct[cy1.tid].data.time == time2) & (ct[cy1.tid].data.type != 0)]) == 0: + # If cy1 was the system center... + if cy1.tid == ptid1: + # Did any other centers survive? + mcc_ids = copy.deepcopy(cf1.cyclones[cy1.parent["id"]].secondary) + mcc_ids.append(cy1.parent["id"]) + survtest = [len(ct[cf1.cyclones[i].tid].data.loc[(ct[cf1.cyclones[i].tid].data.time == time2) \ + & (ct[cf1.cyclones[i].tid].data.type != 0)]) > 0 for i in mcc_ids] + + # If another center did survive, then there is cyclone re-genesis + if sum(survtest) > 0: + # Find the deepest surviving center + mcc_ids_surv = [mcc_ids[i] for i in range(len(mcc_ids)) if survtest[i] == 1] + mcc_ids_surv_p = [float(ct[cf1.cyclones[i].tid].data.loc[ct[cf1.cyclones[i].tid].data.time == time2,"p_cent"]) \ + for i in mcc_ids_surv] + reid1 = mcc_ids_surv[np.where(np.array(mcc_ids_surv_p) == min(mcc_ids_surv_p))[0][0]] + # Find the cf2 version of that center + reid2 = int(ct[cf1.cyclones[reid1].tid].data.loc[ct[cf1.cyclones[reid1].tid].data.time == time2,"id"]) + # Assign it an area regenesis event & make it the primary track + ct[cf2.cyclones[reid2].tid].addEvent(cf2.cyclones[reid2],time2,"ge",2) + ct[cf2.cyclones[reid2].tid].addEvent(cf2.cyclones[reid2],time2,"rg",2,otid=cy1.tid) + ct[cf2.cyclones[reid2].tid].data.loc[ct[cf2.cyclones[reid2].tid].data.time == time2,"ptid"] = cf2.cyclones[reid2].tid + ct[cf2.cyclones[reid2].tid].ptid = cf2.cyclones[reid2].tid + # If no other centers survived, then it's just a normal type 3 lysis --> no change + else: + continue + + else: # cy1 was NOT the system center... + # Then it's just a secondary lysis event --> change event type to 1 + ct[cy1.tid].data.loc[ct[cy1.tid].data.time == time2,"Ely"] = 1 + ct[cy1.tid].events.loc[(ct[cy1.tid].events.time == time2) & (ct[cy1.tid].events.event == "ly"),"Etype"] = 1 + + ######################## + # PART 4.3. AREA SPLIT # + else: + # Otherwise, find the id of cy1 in time of cf2: + c1_id2 = int(ct[cy1.tid].data.loc[ct[cy1.tid].data.time == time2,"id"]) + + # Find track ids for each center in the mcc that isn't cy1 + sa_tids = [cy.tid for cy in cf1.cyclones if ((cy.parent["id"] == cy1.parent["id"]) and (cy.id != cy1.id))] + + for ti in sa_tids: # for each track id + if len(ct[ti].data.loc[(ct[ti].data.time == time2) & (ct[ti].data.type != 0),"id"]) == 0: + continue # This was a lysis event, no center to compare + + else: + sa_id2 = int(ct[ti].data.loc[ct[ti].data.time == time2,"id"] )# Find the center id for cf2 + sa_parent_id = cf2.cyclones[sa_id2].parent["id"] # Find parent id at time of cf2 + if sa_parent_id != cf2.cyclones[c1_id2].parent["id"]: # If the two are not the same + # Assign an area split to cy1's track + ct[cy1.tid].addEvent(cf2.cyclones[c1_id2],time2,"sp",2,otid=ti) + if ptid1 != cy1.tid: # If cy1 was NOT the system center + # Then change the track id to its own now that it has split + ct[cy1.tid].data.loc[ct[cy1.tid].data.time == time2,"ptid"] = cy1.tid + ct[cy1.tid].ptid = cy1.tid + # And add an area genesis event + ct[cy1.tid].addEvent(cf2.cyclones[c1_id2],time2,"ge",2) + + ########################################################## + # PART 5. UPDATE ptid OF MULTI-CENTER CYCLONES IN TIME 2 # + ########################################################## + for cy2 in cf2.cyclones: + # Identify MCCs by the primary center + if int(ct[cy2.tid].data.loc[ct[cy2.tid].data.time == time2,"centers"]) > 1: + # For each mcc, identify the cycs and their tids for each center + mcy2s = [cy for cy in cf2.cyclones if cy.parent["id"] == cy2.parent["id"]] + mtids = [cy.tid for cy in cf2.cyclones if cy.parent["id"] == cy2.parent["id"]] + + # Grab the depth and lifespan at time 2: + mp2s = [cy.p_edge - cy.p_cent for cy in mcy2s] + + # Which tracks also existed in cf1? (excludes split genesis markers) + pr_mtids = [ti for ti in mtids if len(ct[ti].data.loc[ct[ti].data.time == time1,"type"]) > 0 \ + and int(ct[ti].data.loc[ct[ti].data.time == time1,"type"]) > 0] + + # If none of the tracks existed in cf1 time, + if len(pr_mtids) == 0: + # then choose the deepest in cf2 time as ptid + ptid2 = mtids[np.where(np.array(mp2s) == max(mp2s))[0][0]] + + # If only one track existed in cf1 time, + elif len(pr_mtids) == 1: + # Use it's tid as the ptid for everything + ptid2 = int(ct[pr_mtids[0]].tid) + + # If more than one track existed in cf1 time, + else: + # Assign the center with the greatest depth + mp2s_pr = [float(ct[ti].data.loc[ct[ti].data.time == time2,"depth"]) for ti in pr_mtids] + tid2 = mtids[np.where(np.array(mp2s) == max(mp2s_pr))[0][0]] # find id of the max cf2 depth + # Note that if two cyclones have the same depth, the first by id is automatically taken. + try: + ptid2 = int(ct[tid2].data.loc[ct[tid2].data.time == time2,"ptid"]) # identify its ptid as ptid for system + except: + ptid2 = tid2 # if it has no ptid, then assign its tid as ptid + + # Loop through all centers in the mcc + for mtid in mtids: + # Assign the ptid to all centers in the mcc + ct[mtid].data.loc[ct[mtid].data.time == time2,"ptid"] = ptid2 + ct[mtid].ptid = ptid2 + + # Add area lysis events to any non-ptid tracks that experienced an area merge + if ct[mtid].tid != ptid2 and int(ct[mtid].data.loc[ct[mtid].data.time == time2,"Emg"]) == 2: + ct[mtid].addEvent(cf2.cyclones[int(ct[mtid].data.loc[ct[mtid].data.time == time2,"id"])],time2,"ly",2,otid=ptid2) + + # For center-only lysis events, set the final ptid to match the last observed ptid + if int(ct[cy2.tid].data.loc[ct[cy2.tid].data.time == time2,"Ely"]) == 1: + ct[cy2.tid].data.loc[ct[cy2.tid].data.time == time2,"ptid"] = int(ct[cy2.tid].data.loc[ct[cy2.tid].data.time == time1,"ptid"]) + ct[cy2.tid].ptid = int(ct[cy2.tid].data.loc[ct[cy2.tid].data.time == time1,"ptid"]) + + if len(cf2.tid()) != len(list(set(cf2.tid()))): + raise Exception("Number of centers in cf2 does not match the number of \ + tracks assigned. Multiple centers may have been assigned to the same track.") + + return ct, cf2 # Return an updated cyclonetrack object (corresponds to ctr) + ### and cyclonefield object for time 2 (corresponds to cfb) + +'''########################### +Split Tracks into Active and Inactive +###########################''' +def splitActiveTracks(ct,cf): + '''Given a list of cyclone tracks, this function creates two new lists: one + with all inactive tracks (tracks that have already experienced lysis) and + one with all active tracks (tracks that have not experienced lysis). Next, + the track ids (and parent track ids) are reset for all active tracks and + the related cyclone field. + + ct = list of cyclone tracks + cf = cyclone field object + + Returns: ([active tracks], [inactive tracks]) + (The cyclone field object is mutable and automatically edited.) + ''' + ct_inactive, ct_active, tid_active = [], [], [] # Create empty lists + # Sort tracks into active and inactive + for track in ct: + if track != 0 and ( (1 in list(track.data.Ely)) or (3 in list(track.data.Ely)) ): + ct_inactive.append(track) + else: + ct_active.append(track) + tid_active.append(track.tid) + + # Reformat tids for active tracks + tid_activeA = np.array(tid_active) + for tr in range(len(ct_active)): + ct_active[tr].tid = tr + ct_active[tr].ftid = tid_active[tr] + if ct_active[tr].ptid in tid_active: + ct_active[tr].ptid = int(np.where(tid_activeA == ct_active[tr].ptid)[0][0]) + + for cyctr in cf.cyclones: + if cyctr.tid in tid_active: + cyctr.tid = int(np.where(tid_activeA == cyctr.tid)[0][0]) + else: + cyctr.tid = np.nan # These are inactive tracks, so they don't matter anymore. + + return ct_active, ct_inactive + +'''########################### +Realign Track IDs for Cyclone Tracks and Cyclone Field +###########################''' +def realignPriorTID(ct,cf1): + '''This is a very specific function used to realign the track ids for a + list of active cyclone tracks and a cyclone field object. The cyclone field + object must correspond to the final time step recorded in the track + objects. Additionally, the cyclone field object must have the same number + of centers as the track list has tracks. + + Inputs: + ct = a list of cyclone track objects + cf1 = a cyclone field object corresponding to the final time recorded in ct + + Output: + no return, but the tids in cf1 are modified. + ''' + + if len(ct) != len(cf1.cyclones): + raise Exception("Number of tracks in ct doesn't equal the number of centers in cf1.") + if (cf1.time != ct[0].data.time.iloc[-1]) or (cf1.time != ct[-1].data.time.iloc[-1]): + raise Exception("The time for cf1 is not the final time recorded for ct.") + else: + for tid in range(len(ct)): # For each track + cid = int(ct[tid].data.loc[ct[tid].data.time == cf1.time,"id"]) # Find the center id for the final time step + cf1.cyclones[cid].tid = tid # Reset the tid for the corresponding center in cf1 + +'''########################### +Convert Cyclone Center Tracks to Cyclone System Tracks +###########################''' +def cTrack2sTrack(ct,cs0=[],dateref=[1900,1,1,0,0,0],rg=0,lyb=1,dpy=365): + '''Cyclone tracking using the trackCyclones function is performed on + cyclone centers, including secondary centers. But since the primary + center of a cyclone at timestep 1 might not share the same track as the + primary center of the same cyclone at timestep 2, it may sometimes be + desirable to follow only that primary center and so receive a system-based + view of tracks. This function performs such a conversion post-tracking, + ensuring that the track of a system always follows the primary center of + that system. All other tracks are then built around that idea. + + ct = a list of cyclonetrack objects + cs0 = a list of systemtrack objects from the prior month (defaults to an + empty list, meaning there is no prior month) + dateref = the reference date to use for determining the month + rg = boolean; if set to 1, then a system track will be extended if one of + the secondary centers continues (a regenesis event); if set to 0, the re- + genesis will be ignored and the surviving secondary will be treated as a + new system. Defaults to 0. + + Two lists of cyclone system objects are returned: one for the current month + (ct -> cs) and an updated one for the prior month (cs0 -> cs0) + ''' + # Define month + mt = timeAdd(dateref,[0,0,list(ct[0].data.time)[-1],0,0,0]) + mt[2], mt[3], mt[4], mt[5] = 1, 0, 0, 0 + days = daysBetweenDates(dateref,mt,lyb,dpy) + + cs = [] + + # STEP 1: LIMIT TO PTID (primary tracks) + for t in ct: # Loop through each original tracks + ptidtest = [t.tid != p for p in t.data.loc[(t.data.type != 0) & (t.data.time >= days),"ptid"]] + \ + [t.ftid != p for p in t.data.loc[(t.data.type != 0) & (t.data.time < days),"ptid"]] # Current Month \ Prior Month + if sum(ptidtest) == 0: # If ptid always equals tid + cs.append(systemtrack(t.data,t.events,t.tid,len(cs),t.ftid)) # Append to system track list + + # If ptid is never equal to tid, the track is always secondary, so ignore it + # But if the ptid is sometimes equal to the tid, the track needs to be split up + elif sum(ptidtest) < len(t.data.loc[t.data.type != 0]): + # Start empty data frames for data and events: + data = pd.DataFrame() + events = pd.DataFrame() + # Observe each time... + + for r in t.data.time: + rdata = t.data.loc[t.data.time == r] # Pull out the row for this time + + # If the track is indepedent at this time step: + if ( (t.tid == int(rdata["ptid"])) or (t.ftid == int(rdata["ptid"])) ) and ( int(rdata["type"]) != 0 ): + # Append the row to the open system + data = data.append(rdata, ignore_index=1, sort=1) + events = events.append(t.events.loc[t.events.time == r], ignore_index=1, sort=1) + + elif len(data) > 0: + # Append the row to the open system + data = data.append(rdata, ignore_index=1, sort=1) + events = events.append(t.events.loc[t.events.time == r], ignore_index=1, sort=1) + # Close the system by adding it to the cs list + cs.append(systemtrack(data,events,t.tid,len(cs),t.ftid)) + # Nullify the final step + nullifyCycloneTrackInstance(cs[-1],r,data.loc[data.time == r,"ptid"]) + # Create a new open system: + data = pd.DataFrame() + events = pd.DataFrame() + + # After last is reached, end the open system if it has any rows + if len(data) > 0: + # Add any lysis events if they exist + events = events.append(t.events.loc[t.events.time == r+(t.data.time.iloc[1] - t.data.time.iloc[0])], ignore_index=1, sort=1) + # Append to cs list + cs.append(systemtrack(data,events,t.tid,len(cs),t.ftid)) + + # STEP 2: COMBINE REGENESIS CASES + cs = np.array(cs) + if rg == 1: + sys_tids = np.array([ccc.tid for ccc in cs]) + + # CASE 1: Dead Track in Prior Month, Regenerated Track in This Month + # Identify the track id of regenerated tracks that died in prior month + rg_otids, rg_tids, dels = [], [], [] + for t in cs: + rgs = np.sum(t.events.loc[t.events.time < days,"event"] == "rg") + if rgs > 0: + rg_tids.append(t.tid) + rg_otids.append(int(t.events.loc[t.events.event == "rg","otid"])) + + otids = np.array([aa.tid for aa in cs0]) + for o in range(len(rg_otids)): # For each dead track + # Note the position of the dead track object + delids = np.where(otids == rg_otids[o])[0] + try: # Try linking to the prior month's track if possible + dels.append(delids[-1]) + + # Extract the dead track objects + tDead = cs0[dels[o]] # Def of regenesis requires that primary track has experience type 3 lysis + + # Extract the regenerated track object + sid_cands = np.where(sys_tids == rg_tids[o])[0] # Candidate track objects + sid_rgcode = np.array([ np.sum(cs[sidc].data.Erg == 2) > 0 and \ + (cs[sidc].data.loc[cs[sidc].data.Erg == 2,"time"].iloc[0] == tDead.data.time.iloc[-1]) \ + for sidc in sid_cands ]) # Does this track have a regeneration? + + sid = sid_cands[np.where(sid_rgcode == 1)[0][0]] # sid of the regenerated track + tRegen = cs[sid] + + # Splice together with the regenerated track + cs[sid].data = tDead.data[:-1].append(tRegen.data.loc[tRegen.data.time >= tDead.data.time.iloc[-1]], ignore_index=1, sort=1) + cs[sid].events = tDead.events[:-1].append(tRegen.events.loc[(tRegen.events.time >= tDead.data.time.iloc[-1])], ignore_index=1, sort=1) + cs[sid].data.loc[cs[sid].data.Erg > 0,"Ely"] = 0 + cs[sid].data.loc[cs[sid].data.Erg > 0,"Ege"] = 0 + except: + continue + + # CLEAN UP + # Remove the dead tracks from the current month + cs0 = np.delete(cs0,dels) + + # CASE 2: Dead Track and Regenerated Track in Same Month + # Identify the track id of regenerated tracks that died this month + rg_otids, rg_tids, dels = [], [], [] + for t in cs: + rgs = np.sum(t.events.loc[t.events.time >= days,"event"] == "rg") + if rgs > 0: + rg_tids.append(t.tid) + rg_otids.append(int(t.events.loc[t.events.event == "rg","otid"])) + + for o in range(len(rg_otids)): # For each dead track + try: + # Note the position of the dead track object + dels.append(np.where(sys_tids == rg_otids[o])[0][-1]) + + # Extract the dead track object + tDead = cs[dels[o]] # Def of regenesis requires that primary track has experienced type 3 lysis + + # Extract the regenerated track object + sid_cands = np.where(sys_tids == rg_tids[o])[0] # Candidate track objects + # Does this track have a regeneration? And does it begin when the dead track ends? + sid_rgcode = np.array([("rg" in list(cs[sidc].events.event) ) and \ + ( cs[sidc].data.time.iloc[0] == tDead.events.loc[tDead.events.event == "ly","time"].iloc[-1] ) \ + for sidc in sid_cands]) + sid = sid_cands[np.where(sid_rgcode == 1)[0][0]] # sid of the regenerated track + tRegen = cs[sid] + + # Splice together with the regenerated track + cs[sid].data = tDead.data[:-1].append(tRegen.data,ignore_index=1, sort=1) + cs[sid].events = tDead.events[:-1].append(tRegen.events.loc[(tRegen.events.event != "ge") | \ + (tRegen.events.time > tRegen.events.time.iloc[0])],ignore_index=1, sort=1) + cs[sid].data.loc[cs[sid].data.Erg > 0,"Ely"] = 0 + cs[sid].data.loc[cs[sid].data.Erg > 0,"Ege"] = 0 + except: + continue + + # CLEAN UP + # Remove the dead tracks from the current month + cs = np.delete(cs,dels) + + # Re-format SIDs + for c in range(len(cs)): + cs[c].sid = c + + return list(cs), list(cs0) + +'''########################### +Calculate the Circular Mean Array of a Set of Arrays +###########################''' +def meanCircular(a,amin,amax,favorMax=1): + '''Given a list of values (a), this function appends one member at a time. + Weights are assigned for each appending step based on how many members + have already been appended. If the resultant mean crosses the min/max + boundary at any point, it is redefined as is the min and max are the same + point. + + a = a list of values + amin = the minimum on the circle + amax = the maximum on the circle + favorMax = if 1 (default), the maximum is always returned (never the minimum); + if 0, the minimum is always returned (never the maximum) + ''' + circum = amax-amin # calculate range (circumference) of the circle + # Sort from largest to smallest + aa = copy.deepcopy(a) + aa.sort() + aa.reverse() + + for i,v in enumerate(aa): # for each value in a + v = float(v) + if i == 0: # if it's the first value, value = mean + mean = v + else: # otherwise... + arc = mean-v # calculate arc length that doesn't cross min/max + if arc/circum < 0.5: # if that arc length is less than half the total circumference + mean = mean*i/(i+1) + v/(i+1) # then weight everything like normal; making the mean smaller + else: # otherwise, the influence will pull the mean toward min/max + mean = mean*i/(i+1) + (v+circum)/(i+1) # making the mean larger + + # After nudging the mean, check to see if it crossed the min/max line + ## Adjust value if necessary + if mean < amin: + mean = amax-amin+mean + elif mean > amax: + mean = amin-amax+mean + + # Check for minimum/maximum and replace if necessary + if mean == amin and favorMax == 1: + mean = amax + + elif mean == amax and favorMax == 0: + mean = amin + + return mean + +def meanArraysCircular_nan(arrays,amin,amax,favorMax=1,dtype=float): + '''Given a list of arrays, this function applies a circular mean function + to each array location across all arrays. Returns an array with the same + dimensions as the inputs (all of which must have the same dimensions). + NaNs are eliminated from consideration. + + a = a list of values + amin = the minimum on the circle + amax = the maximum on the circle + favorMax = if 1 (default), the maximum is always returned (never the minimum); + if 0, the minimum is walawyas returned (never the maximum) + arrays = A list or tuple of numpy arrays + dtype = The desired output data type for array elements (defaults to python float) + ''' + means = np.zeros(arrays[0].shape, dtype=dtype) + + # Loop through each location + for r in range(arrays[0].shape[0]): + for c in range(arrays[0].shape[1]): + # Collect values across all arrays + #print(str(r) + " of " + str(arrays[0].shape[0]) + ", " + str(c) + " of " + str(arrays[0].shape[1])) + a = [] + for i in range(len(arrays)): + if np.isnan(arrays[i][r,c]) == 0: # Only if a non-nan value + a.append(arrays[i][r,c]) + + # Calculate circular mean + if len(a) > 0: + means[r,c] = meanCircular(a,amin,amax,favorMax=favorMax) + else: + means[r,c] = np.nan + + return means + +'''########################### +Aggregate the Event Frequency for a Month of Cyclone Tracks +###########################''' +def aggregateEvents(tracks,typ,days,shape): + '''Aggregates cyclone events (geneis, lysis, splitting, and merging) for + a given month and returns a list of 4 numpy arrays in the order + [gen, lys, spl, mrg] recording the event frequency + + tracks = a list or tuple of tracks in the order [trs,trs0,trs2], where + trs = the current month, trs0 = the previous month, and trs2 = the active + tracks remaining at the end of the current month + typ = "cyclone" or "system" + days = the time in days since a common reference date for 0000 UTC on the + 1st day of the current month + shape = a tuple of (r,c) where r and c are the number of rows and columns, + respectively, for the output + ''' + fields = [np.zeros(shape),np.zeros(shape),np.zeros(shape),np.zeros(shape)] + + if typ.lower() == "cyclone": + excludeType = 2 + else: + excludeType = 1 + + # Limit events to only those tracks that satisfy above criteria + tids = [tr.tid for tr in tracks[0]] + ftids = [tr.ftid for tr in tracks[0]] + tids0 = [tr.tid for tr in tracks[1]] + ftids2 = [tr.ftid for tr in tracks[2]] + + for tr in tracks[0]: # For each track + # Record first and last instance as genesis and lysis, respectively + fields[0][int(tr.data.y.iloc[0]),int(tr.data.x.iloc[0])] = fields[0][int(tr.data.y.iloc[0]),int(tr.data.x.iloc[0])] + 1 + fields[1][int(tr.data.y.iloc[-1]),int(tr.data.x.iloc[-1])] = fields[1][int(tr.data.y.iloc[-1]),int(tr.data.x.iloc[-1])] + 1 + + for e in range(len(tr.events)): # Check the stats for each event + if tr.events.Etype.iloc[e] != excludeType: # Area-only or Point-only events may not be of interest + y = int( tr.events.y.iloc[e] ) + x = int( tr.events.x.iloc[e] ) + # For splits, merges, and re-genesis, only record the event if the + ## interacting track also satisfies the lifespan/track length criteria + # If the event time occurs during the month of interest... + # Check if the otid track exists in either this month or the next month: + if tr.events.time.iloc[e] >= days and ( (tr.events.otid.iloc[e] in tids) or (tr.events.otid.iloc[e] in ftids2) ): + # And if so, record the event type + if tr.events.event.iloc[e] == "mg": + fields[3][y,x] = fields[3][y,x] + 1 + elif tr.events.event.iloc[e] == "sp": + fields[2][y,x] = fields[2][y,x] + 1 + # If the event time occurs during the previous month... + # Check if the otid track exists in either this month or the previous month: + elif tr.events.time.iloc[e] < days and ( (tr.events.otid.iloc[e] in tids0) or (tr.events.otid.iloc[e] in ftids) ): + # And if so, record the event type + if tr.events.event.iloc[e] == "mg": + fields[3][y,x] = fields[3][y,x] + 1 + elif tr.events.event.iloc[e]== "sp": + fields[2][y,x] = fields[2][y,x] + 1 + + return fields + +'''########################### +Aggregate Track-wise Stats for a Month of Cyclone Tracks +###########################''' +def aggregateTrackWiseStats(trs,date,shape): + '''Aggregates cyclone stats that have a single value for each track: + max propagation speed, max deepening rate, max depth, min central pressure, + max laplacian of central pressure. Returns a list containing five numpy + arrays for the frequency of the extremes at each location in the order: + max propagation speed, max deepening rate, + max depth, min central pressure, max laplacian of central pressure + + trs = List of cyclone track objects for current month + date = A date in the format [Y,M,D] or [Y,M,D,H,M,S] + shape = a tuple of (r,c) where r and c are the number of rows and columns, + respectively, for the output + ''' + # Prep inputs + maxuv_field, maxdpdt_field, maxdep_field, minp_field, maxdsqp_field = \ + np.zeros(shape),np.zeros(shape),np.zeros(shape),np.zeros(shape),np.zeros(shape) + + # Look at each track and aggregate stats + for tr in trs: + # Collect Track-Wise Stats + trmaxuv = tr.maxUV() + for i in range(len(trmaxuv[1])): + maxuv_field[trmaxuv[2][i],trmaxuv[3][i]] = maxuv_field[trmaxuv[2][i],trmaxuv[3][i]] + 1 + trmaxdpdt = tr.maxDpDt() + for i in range(len(trmaxdpdt[1])): + maxdpdt_field[trmaxdpdt[2][i],trmaxdpdt[3][i]] = maxdpdt_field[trmaxdpdt[2][i],trmaxdpdt[3][i]] + 1 + trmaxdsqp = tr.maxDsqP() + for i in range(len(trmaxdsqp[1])): + maxdsqp_field[trmaxdsqp[2][i],trmaxdsqp[3][i]] = maxdsqp_field[trmaxdsqp[2][i],trmaxdsqp[3][i]] + 1 + trmaxdep = tr.maxDepth() + for i in range(len(trmaxdep[1])): + maxdep_field[trmaxdep[2][i],trmaxdep[3][i]] = maxdep_field[trmaxdep[2][i],trmaxdep[3][i]] + 1 + trminp = tr.minP() + for i in range(len(trminp[1])): + minp_field[trminp[2][i],trminp[3][i]] = minp_field[trminp[2][i],trminp[3][i]] + 1 + + return [maxuv_field, maxdpdt_field, maxdep_field, minp_field, maxdsqp_field] + +'''########################### +Aggregate Point-wise Stats for a Month of Cyclone Tracks +###########################''' +def aggregatePointWiseStats(trs,n,shape): + '''Aggregates cyclone counts, track density, and a host of other Eulerian + measures of cyclone characteristics. Returns a list of numpy arrays in the + following order: track density, cyclone center frequnecy, cyclone center + frequency for centers with valid pressure, and multi-center cyclone + frequnecy; the average propagation speed, propogation direction, radius, + area, depth, depth/radius, deepening rate, central pressure, and laplacian + of central pressure. + + trs = List of cyclone track objects for current month + n = The number of time slices considered in the creation of trs (usually the + number of days in the given month times 24 hours divided by the time interval in hours) + shape = A tuple of (r,c) where r and c are the number of rows and columns, + respectively, for the output + ''' + # Ensure that n is a float + n = float(n) + + # Create empty fields + sys_field, trk_field, countU_field, countA_field, countP_field = \ + np.zeros(shape), np.zeros(shape), np.zeros(shape), np.zeros(shape), np.zeros(shape) + + pcent_field, dpdt_field, dpdr_field, dsqp_field, depth_field = \ + np.zeros(shape), np.zeros(shape), np.zeros(shape), np.zeros(shape), np.zeros(shape) + + uvDi_fields, uvAb_field, radius_field, area_field, mcc_field = \ + [], np.zeros(shape), np.zeros(shape), np.zeros(shape), np.zeros(shape) + + for tr in trs: + uvDi_field = np.zeros(shape) + + # Count Point-Wise Stats + trk_tracker = np.zeros(shape) # This array tracks whether the track has been counted yet in each grid cell + for i in range(len(tr.data))[:-1]: + x = int(tr.data.x.iloc[i]) + y = int(tr.data.y.iloc[i]) + + # Existance of System/Track + sys_field[y,x] = sys_field[y,x] + 1 + if trk_tracker[y,x] == 0: # Only count in trk_field if it hasn't yet been counted there! + trk_field[y,x] = trk_field[y,x] + 1 + trk_tracker[y,x] = trk_tracker[y,x] + 1 + # Special Cases: + if i > 0: + countU_field[y,x] = countU_field[y,x] + 1 + if tr.data.radius.iloc[i] != 0: + countA_field[y,x] = countA_field[y,x] + 1 + if np.isnan(tr.data.p_cent.iloc[i]) != 1: + countP_field[y,x] = countP_field[y,x] + 1 + + # Other Eulerian Measures + pcent_field[y,x] = pcent_field[y,x] + float(np.where(np.isnan(tr.data.p_cent.iloc[i]) == 1,0,tr.data.p_cent.iloc[i])) + dpdt_field[y,x] = dpdt_field[y,x] + float(np.where(np.isnan(tr.data.DpDt.iloc[i]) == 1,0,tr.data.DpDt.iloc[i])) + dpdr_field[y,x] = dpdr_field[y,x] + float(np.where(np.isnan(tr.data.DpDr.iloc[i]) == 1,0,tr.data.DpDr.iloc[i])) + dsqp_field[y,x] = dsqp_field[y,x] + float(np.where(np.isnan(tr.data.DsqP.iloc[i]) == 1,0,tr.data.DsqP.iloc[i])) + depth_field[y,x] = depth_field[y,x] + float(np.where(np.isnan(tr.data.depth.iloc[i]) == 1,0,tr.data.depth.iloc[i])) + uvAb_field[y,x] = uvAb_field[y,x] + float(np.where(np.isnan(tr.data.uv.iloc[i]) == 1,0,tr.data.uv.iloc[i])) + uvDi_field[y,x] = uvDi_field[y,x] + vectorDirectionFrom(tr.data.u.iloc[i],tr.data.v.iloc[i]) + radius_field[y,x] = radius_field[y,x] + float(np.where(np.isnan(tr.data.radius.iloc[i]) == 1,0,tr.data.radius.iloc[i])) + area_field[y,x] = area_field[y,x] + float(np.where(np.isnan(tr.data.area.iloc[i]) == 1,0,tr.data.area.iloc[i])) + mcc_field[y,x] = mcc_field[y,x] + float(np.where(float(tr.data.centers.iloc[i]) > 1,1,0)) + + uvDi_fields.append(np.where(uvDi_field == 0,np.nan,uvDi_field)) + + ### AVERAGES AND DENSITIES ### + uvDi_fieldAvg = meanArraysCircular_nan(uvDi_fields,0,360) + + pcent_fieldAvg = pcent_field/countP_field + dpdt_fieldAvg = dpdt_field/countU_field + dpdr_fieldAvg = dpdr_field/countA_field + dsqp_fieldAvg = dsqp_field/countP_field + depth_fieldAvg = depth_field/countA_field + uvAb_fieldAvg = uvAb_field/countU_field + radius_fieldAvg = radius_field/countP_field + area_fieldAvg = area_field/countP_field + + return [trk_field, sys_field/n, countP_field/n, countU_field/n, countA_field/n, mcc_field/n, \ + uvAb_fieldAvg, uvDi_fieldAvg, radius_fieldAvg, area_fieldAvg, depth_fieldAvg, \ + dpdr_fieldAvg, dpdt_fieldAvg, pcent_fieldAvg, dsqp_fieldAvg] + +'''########################### +Aggregate Fields that Exist for Each Time Step in a Month of Cyclone Tracking +###########################''' +def aggregateTimeStepFields(inpath,trs,mt,timestep,dateref=[1900,1,1,],lyb=1,dpy=365): + '''Aggregates fields that exist for each time step in a month of cyclone + tracking data. Returns a list of numpy arrays. + + inpath = a path to the directory for the cyclone detection/tracking output + (should end with the folder containing an "AreaFields" folder) + mt = month time in format [Y,M,1] or [Y,M,1,0,0,0] + timestep = timestep in format [Y,M,D] or [Y,M,D,H,M,S] + lys = 1 for Gregorian calendar, 0 for 365-day calendar + ''' + # Supports + monthstep = [0,1,0,0,0,0] + months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] + mons = ["01","02","03","04","05","06","07","08","09","10","11","12"] + days = ["01","02","03","04","05","06","07","08","09","10","11","12","13",\ + "14","15","16","17","18","19","20","21","22","23","24","25","26","27",\ + "28","29","30","31"] + hours = ["0000","0100","0200","0300","0400","0500","0600","0700","0800",\ + "0900","1000","1100","1200","1300","1400","1500","1600","1700","1800",\ + "1900","2000","2100","2200","2300"] + + # Start timers + t = mt + tcount = 0 + + # Create an empty array to start + date = str(t[0])+mons[t[1]-1]+days[t[2]-1]+"_"+hours[t[3]] + cf = pd.read_pickle(inpath[:-7]+"/CycloneFields/"+str(t[0])+"/"+months[t[1]-1]+"/CF"+date+".pkl") + fieldAreas = 0*cf.fieldAreas + + while t != timeAdd(mt,monthstep): + date = str(t[0])+mons[t[1]-1]+days[t[2]-1]+"_"+hours[t[3]] + + # Load Cyclone Field for this time step + cf = pd.read_pickle(inpath[:-7]+"/CycloneFields/"+str(t[0])+"/"+months[t[1]-1]+"/CF"+date+".pkl") + cAreas, nC = ndimage.measurements.label(cf.fieldAreas) + + # For each track... + for tr in trs: + d = daysBetweenDates(dateref,t,lyb,dpy) + try: + x = int(tr.data.loc[(tr.data.time == d) & (tr.data.type != 0),"x"].iloc[0]) + y = int(tr.data.loc[(tr.data.time == d) & (tr.data.type != 0),"y"].iloc[0]) + + # Add the area for this time step + fieldAreas = fieldAreas + np.where(cAreas == cAreas[y,x], 1, 0) + + except: + continue + + # Increment time step + tcount = tcount+1 + t = timeAdd(t,timestep,lyb,dpy) + return [fieldAreas/tcount] + +'''########################### +Calculate the Distance Between Two (Lat,lon) Locations +###########################''' +def haversine(lats1, lats2, lons1, lons2, units="meters", R = 6371000): + '''This function uses the haversine formula to calculate the distance between + two points on the Earth's surface when given the latitude and longitude (in decimal + degrees). It returns a distance in the units specified (default is meters). If + concerned with motion, (lats1,lons1) is the initial position and (lats2,lons2) + is the final position. + + R = Radius of the Earth in meters + ''' + import numpy as np + + # Convert to radians: + lat1, lat2 = lats1*np.pi/180, lats2*np.pi/180 + lon1, lon2 = lons1*np.pi/180, lons2*np.pi/180 + + # Perform distance calculation: + a = np.sin((lat2-lat1)/2)**2 + np.cos(lat1)*np.cos(lat2)*np.sin((lon2-lon1)/2)**2 + c = 2*np.arctan2(np.sqrt(a),np.sqrt(1-a)) + d = R*c + + # Conversions: + if units.lower() in ["m","meters","metres","meter","metre"]: + d = d + elif units.lower() in ["km","kms","kilometer","kilometre","kilometers","kilometres"]: + d = d/1000 + elif units.lower() in ["ft","feet"]: + d = d*3.28084 + elif units.lower() in ["mi","miles"]: + d = d*0.000621371 + + return d + +'''########################### +Calculate the area bounded by a pair of longitudes and a pair of latitudes (i.e., a bounding box) +###########################''' +def areaBoundingBox(lats1, lats2, lons1, lons2, R = 6371000): + '''This function calculates the area between a pair of ongitudes and a pair + latitudes. It's useful for finding the area of cells in a rectilinear lat/lon + grid or finding the area of a simple (rectangular) bounding box. + Output is in square m. + + R = Radius of the Earth in meters + ''' + + return (np.pi/180)*np.square(R)*np.abs(np.sin(lats1*np.pi/180)-np.sin(lats2*np.pi/180))*np.abs(lons1-lons2) + + +'''########################### +Calculate the Direction a Vector is Coming From +###########################''' +def vectorDirectionFrom(u,v,deg=1): + '''This function calculates the direction a vector is coming from when given + a u and v component. By default, it will return a value in degrees. Set + deg = 0 to get radians instead. + + Returns a array of value(s) in the range (0,360], with a 0 indicating no movement. + ''' + # Take the 180 degree arctangent + ### Rotate results counter-clockwise by 90 degrees + uvDi = 0.5*np.pi - np.arctan2(-v,-u) # Use negatives because it's FROM + + + # If you get a negative value (or 0), add 2 pi to make it positive + uvDi = np.where(uvDi <= 0, uvDi+2*np.pi, uvDi) + + # Set to 0 if no motion occurred + uvDi = np.where( (u == 0) & (v == 0), 0, uvDi) + + # If the answer needs to be in degrees, convert + if deg == 1: + uvDi = 180*uvDi/np.pi + + return uvDi + +'''########################### +Create a Discrete Array from a Continuous Array +###########################''' +def toDiscreteArray(inArray, breaks): + ''' + Given a continuous numpy array, converts all values into discrete (ordinal) + bins. Values that do not fall within the defined breaks are reclassified + as np.nan.\n + + inArray = A numpy array of continuous numerical data + breaks = A list of *fully inclusive* breakpoints. Use -np.inf and np.inf + for extending the color bar beyond a minimum and maximum, respectively. + All bins are b[i-1] <= x < b[i] except for the final bin, which is + b[n-2] <= x <= b[n-1], where n is the number of breaks [n = len(breaks)]. + + ''' + # Initialize output as all NaN values + outArray = np.zeros_like(inArray)*np.nan + + # Set the start and end of the while loop + b = 1 + end = len(breaks) - 1 + + # Discretize each bin that has an <= x < structure + while b < end: + outArray[np.where((inArray >= breaks[b-1]) & (inArray < breaks[b]))] = b + b += 1 + + # Discretize the final bin, which has an <= x <= structure + outArray[np.where((inArray >= breaks[b-1]) & (inArray <= breaks[b]))] = b + + return outArray + +'''########################## +Linear Model (OLS Regression) for 3-D array with shape (t,y,x) +##########################''' +def lm(x,y,minN=10): + '''Calculates a ordinary least squares regression model. Designed + specifically for quick batch trend analayses. Does not consider any + autoregression.\n + + x = the time variable (a list or 1-D numpy array)\n + y = the dependent variable (a 3-D numpy array where axis 0 is the time axis)\n + minN = minimum number of non-NaN values required for y in the x dimension + (e.g., number of years with valid data)\n + + returns 5 numpy arrays with the same dimensions as axis 1 and axis 2 of y: + b (the slope coefficient), a (the intercept coefficient), r^2, + p-value for b, and standard error for b + ''' + # Create empty arrays for output + b, a, r, p, se = np.zeros_like(y[0])*np.nan, np.zeros_like(y[0])*np.nan, np.zeros_like(y[0])*np.nan, np.zeros_like(y[0])*np.nan, np.zeros_like(y[0])*np.nan + + # Find locations with at least minN finite values + n = np.isfinite(y).sum(axis=0) + validrows, validcols = np.where( n >= minN ) + + # For each row/col + for i in range(validrows.shape[0]): + ro, co = validrows[i], validcols[i] + + yin = y[:,ro,co] + + # Create a linear model + b[ro,co], a[ro,co], r[ro,co], p[ro,co], se[ro,co] = stats.linregress(x[np.isfinite(yin)],yin[np.isfinite(yin)]) + + return b, a, r*r, p, se + + +'''########################## +Standard Deviation for 3-D array with shape (t,y,x) +##########################''' +def sd_unbiased(data,ddof=1): + ''' + Calculates an unbiased estimate of standard deviation using the method + described by Ben W. Bolch in "More on unbiased estimation of the standard deviation", + The American Statistician, 22(3), p. 27 (1968). Degrees of freedom are set to n-1 by default. + Don't use this function when n > 100. It's not worth it becuase the correction is so small. Also, + at n > 172, the function starts return non-finite values. + + data = a list or array of values in the sample + ddof = the adjustment to calculate degrees of freedom from sample size (defaults to 1) + + ''' + # Initial standard deviation calculation for sample (dof = n-1) + s = np.std(data,ddof=ddof) + + # Identify sample size + n = len(data) + + # Calculate the correction using dof = n-1 + c4 = np.sqrt(2/(n-ddof)) * special.gamma(n/2) / special.gamma((n-ddof)/2) + + # Apply correction + return s/c4 + +'''########################## +Standard Deviation for 3-D array with shape (t,y,x) +##########################''' +def sd(arr,minN=10): + '''Calculates a standard deviation for 3-D arrays with NaNs. + + arr = the input variable (a 3-D numpy array where axis 0 is the time axis)\n + minN = minimum number of non-NaN values required for arr in the t dimension + (e.g., number of years with valid data)\n + + returns a numpy array with standard deviation or NaN, depending on number of non-NaN values + ''' + # Create empty arrays for output + sd= np.zeros_like(arr[0])*np.nan + + # Find locations with at least minN finite values + n = np.isfinite(arr).sum(axis=0) + validrows, validcols = np.where( n >= minN ) + + # For each row/col + for i in range(validrows.shape[0]): + ro, co = validrows[i], validcols[i] + + # Calculate the standard deviation + sd[ro,co] = np.nanstd( arr[:,ro,co] ) + + return sd + +'''######################### +Compare Tracks from Different Datasets +#########################''' +def comparetracks(trs1,trs2,trs2b,date1,refdate=[1900,1,1,0,0,0],minmatch=0.6,maxsep=500,system=True,lyb=1,dpy=365): + '''This function performs a track-matching comparison between two different + sets of tracks. The tracks being compared should be from the same month and + have the same temporal resolution. They should differ based on input data, + spatial resolution, or detection/tracking parameters. The function returns + a pandas dataframe with a row for each cyclone track in first dataset. If + one exists, the cyclone track in the second dataset that best matches is + compared by the separation distance and intensity differences (central + pressure, its Laplacian, area, and depth).\n + + trs1 = A list of cyclone track objects from the first version + trs2 = A list of cyclone track objects from the second version; must be for + the same month as trs1 + trs2b = A list of cylone track objects from the second vesion; must be for + one month prior to trs1 + date1 = A date in the format [YYYY,MM,1,0,0,0], corresponds to trs1 + refdate = The reference date used during cyclone tracking in the format + [YYYY,MM,DD,HH,MM,SS], by default [1900,1,1,0,0,0] + minmatch = The minimum ratio of matched times to total times for any two + cyclone tracks to be considered a matching pair... using the equation + 2*N(A & B) / (N(A) + N(B)) >= minmatch, where N is the number of + observation times, and A and B are the tracks being compared + maxsep = The maximum allowed separation between a matching pair of tracks; + separation is calculated as the average distance between the tracks + during matching observation times using the Haversine formula + system = whether comparison is between system tracks or cyclone tracks; + default is True, meaning that system tracks are being compared. + ''' + refday = daysBetweenDates(refdate,date1,lyb,dpy) + timeb = timeAdd(date1,[0,-1,0],lyb,dpy) + + ##### System Tracks ##### + if system == True: + pdf = pd.DataFrame() + + # For each track in version 1, find all of the version 2 tracks that overlap at least *minmatch* (e.g. 60%) of the obs times + for i1 in range(len(trs1)): + # Extract the observation times for the version 1 track + times1 = np.array(trs1[i1].data.time[trs1[i1].data.type != 0]) + lats1 = np.array(trs1[i1].data.lat[trs1[i1].data.type != 0]) + lons1 = np.array(trs1[i1].data.lon[trs1[i1].data.type != 0]) + + ids2, ids2b = [], [] # Lists in which to store possible matches from version 2 + avgdist2, avgdist2b = [], [] # Lists in which to store the average distances between cyclone tracks + for i2 in range(len(trs2)): + # Extract the observation times for the version 2 track + times2 = np.array(trs2[i2].data.time[trs2[i2].data.type != 0]) + # Assess the fraction of matching observations + matchfrac = 2*np.sum([t in times2 for t in times1]) / float(len(times1) + len(times2)) + # If that's satisfied, calculate the mean separation for matching observation times + if matchfrac >= minmatch: + timesm = [t for t in times1 if t in times2] # Extract matched times + lats2 = np.array(trs2[i2].data.lat[trs2[i2].data.type != 0]) + lons2 = np.array(trs2[i2].data.lon[trs2[i2].data.type != 0]) + + # Calculate the mean separation between tracks + avgdist2.append( np.mean( [haversine(lats1[np.where(times1 == tm)][0],lats2[np.where(times2 == tm)][0],\ + lons1[np.where(times1 == tm)][0],lons2[np.where(times2 == tm)][0],units='km') for tm in timesm] ) ) + + # And store the track id for the version 2 cyclone + ids2.append(i2) + + # If the version 1 track also existed last month, check last month's version 2 tracks, too... + if times1[0] < refday: + for i2b in range(len(trs2b)): + # Extract the observation times for the version 2b track + times2b = np.array(trs2b[i2b].data.time[trs2b[i2b].data.type != 0]) + # Assess the fraction of matching observations + matchfrac = 2*np.sum([t in times2b for t in times1]) / float(len(times1) + len(times2b)) + if matchfrac >= minmatch: + timesmb = [t for t in times1 if t in times2b] # Extract matched times + lats2b = np.array(trs2b[i2b].data.lat[trs2b[i2b].data.type != 0]) + lons2b = np.array(trs2b[i2b].data.lon[trs2b[i2b].data.type != 0]) + + # Calculate the mean separation between tracks + avgdist2b.append( np.mean( [haversine(lats1[np.where(times1 == tmb)][0],lats2b[np.where(times2b == tmb)][0],\ + lons1[np.where(times1 == tmb)][0],lons2b[np.where(times2b == tmb)][0],units='km') for tmb in timesmb] ) ) + + # And store the track id for the version 2b cyclone + ids2b.append(i2b) + + # Identify how many possible matches satisfy the maximum average separation distance + nummatch = np.where(np.array(avgdist2+avgdist2b) < maxsep)[0].shape[0] + + # Determine which version 2(b) track has the shortest average separation + if nummatch == 0: # If there's no match... + pdf = pdf.append(pd.DataFrame([{"Year1":date1[0],"Month1":date1[1],"sid1":trs1[i1].sid,"Num_Matches":nummatch,"Year2":np.nan,"Month2":np.nan,\ + "sid2":np.nan,"Dist":np.nan,"pcentDiff":np.nan,"areaDiff":np.nan,"depthDiff":np.nan,"dsqpDiff":np.nan},]), ignore_index=1, sort=1) + + elif np.min(avgdist2+[np.inf]) > np.min(avgdist2b+[np.inf]): # If the best match is from previous month... + im = ids2b[np.where(avgdist2b == np.min(avgdist2b))[0][0]] + timesmb = [t for t in times1 if t in np.array(trs2b[im].data.time[trs2b[im].data.type != 0])] # Extract matched times + + # Find average intensity differences + areaDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tmb,"area"]) - float(trs2b[im].data.loc[trs2b[im].data.time == tmb,"area"]) for tmb in timesmb]) + pcentDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tmb,"p_cent"]) - float(trs2b[im].data.loc[trs2b[im].data.time == tmb,"p_cent"]) for tmb in timesmb]) + dsqpDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tmb,"DsqP"]) - float(trs2b[im].data.loc[trs2b[im].data.time == tmb,"DsqP"]) for tmb in timesmb]) + depthDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tmb,"depth"]) - float(trs2b[im].data.loc[trs2b[im].data.time == tmb,"depth"]) for tmb in timesmb]) + + pdf = pdf.append(pd.DataFrame([{"Year1":date1[0],"Month1":date1[1],"sid1":trs1[i1].sid,"Num_Matches":nummatch,"Year2":timeb[0],"Month2":timeb[1],"sid2":trs2b[im].sid,\ + "Dist":np.min(avgdist2b),"pcentDiff":pcentDiff,"areaDiff":areaDiff,"depthDiff":depthDiff,"dsqpDiff":dsqpDiff},]), ignore_index=1, sort=1) + + else: # If the best match is from current month... + im = ids2[np.where(avgdist2 == np.min(avgdist2))[0][0]] + timesm = [t for t in times1 if t in np.array(trs2[im].data.time[trs2[im].data.type != 0])] # Extract matched times + + # Find average intensity differences + areaDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tm,"area"]) - float(trs2[im].data.loc[trs2[im].data.time == tm,"area"]) for tm in timesm]) + pcentDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tm,"p_cent"]) - float(trs2[im].data.loc[trs2[im].data.time == tm,"p_cent"]) for tm in timesm]) + dsqpDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tm,"DsqP"]) - float(trs2[im].data.loc[trs2[im].data.time == tm,"DsqP"]) for tm in timesm]) + depthDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tm,"depth"]) - float(trs2[im].data.loc[trs2[im].data.time == tm,"depth"]) for tm in timesm]) + + pdf = pdf.append(pd.DataFrame([{"Year1":date1[0],"Month1":date1[1],"sid1":trs1[i1].sid,"Num_Matches":nummatch,"Year2":date1[0],"Month2":date1[1],"sid2":trs2[im].sid,\ + "Dist":np.min(avgdist2),"pcentDiff":pcentDiff,"areaDiff":areaDiff,"depthDiff":depthDiff,"dsqpDiff":dsqpDiff},]), ignore_index=1, sort=1) + + # If the version 1 track only existed in the current month... + else: + # Identify how many possible matches satisfy the maximum average separation distance + nummatch = np.where(np.array(avgdist2) < maxsep)[0].shape[0] + + # Determine which version 2 track has the shortest average separation + if nummatch == 0: # If there's no match... + pdf = pdf.append(pd.DataFrame([{"Year1":date1[0],"Month1":date1[1],"sid1":trs1[i1].sid,"Num_Matches":nummatch,"Year2":np.nan,"Month2":np.nan,\ + "sid2":np.nan,"Dist":np.nan,"pcentDiff":np.nan,"areaDiff":np.nan,"depthDiff":np.nan,"dsqpDiff":np.nan},]), ignore_index=1, sort=1) + + else: # If the best match is from current month... + im = ids2[np.where(avgdist2 == np.min(avgdist2))[0][0]] + timesm = [t for t in times1 if t in np.array(trs2[im].data.time[trs2[im].data.type != 0])] # Extract matched times + + # Find average intensity differences + areaDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tm,"area"]) - float(trs2[im].data.loc[trs2[im].data.time == tm,"area"]) for tm in timesm]) + pcentDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tm,"p_cent"]) - float(trs2[im].data.loc[trs2[im].data.time == tm,"p_cent"]) for tm in timesm]) + dsqpDiff = np.nanmean([float(trs1[i1].data.loc[trs1[i1].data.time == tm,"DsqP"]) - float(trs2[im].data.loc[trs2[im].data.time == tm,"DsqP"]) for tm in timesm]) + depthDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tm,"depth"]) - float(trs2[im].data.loc[trs2[im].data.time == tm,"depth"]) for tm in timesm]) + + pdf = pdf.append(pd.DataFrame([{"Year1":date1[0],"Month1":date1[1],"sid1":trs1[i1].sid,"Num_Matches":nummatch,"Year2":date1[0],"Month2":date1[1],"sid2":trs2[im].sid,\ + "Dist":np.min(avgdist2),"pcentDiff":pcentDiff,"areaDiff":areaDiff,"depthDiff":depthDiff,"dsqpDiff":dsqpDiff},]), ignore_index=1, sort=1) + + ####### Cyclone Tracks ####### + else: + pdf = pd.DataFrame() + + # For each track in version 1, find all of the version 2 tracks that overlap at least *minmatch* (e.g. 60%) of the obs times + for i1 in range(len(trs1)): + # Extract the observation times for the version 1 track + times1 = np.array(trs1[i1].data.time[trs1[i1].data.type != 0]) + lats1 = np.array(trs1[i1].data.lat[trs1[i1].data.type != 0]) + lons1 = np.array(trs1[i1].data.lon[trs1[i1].data.type != 0]) + + ids2, ids2b = [], [] # Lists in which to store possible matches from version 2 + avgdist2, avgdist2b = [], [] # Lists in which to store the average distances between cyclone tracks + for i2 in range(len(trs2)): + # Extract the observation times for the version 2 track + times2 = np.array(trs2[i2].data.time[trs2[i2].data.type != 0]) + # Assess the fraction of matching observations + matchfrac = 2*np.sum([t in times2 for t in times1]) / float(len(times1) + len(times2)) + # If that's satisfied, calculate the mean separation for matching observation times + if matchfrac >= minmatch: + timesm = [t for t in times1 if t in times2] # Extract matched times + lats2 = np.array(trs2[i2].data.lat[trs2[i2].data.type != 0]) + lons2 = np.array(trs2[i2].data.lon[trs2[i2].data.type != 0]) + + # Calculate the mean separation between tracks + avgdist2.append( np.mean( [haversine(lats1[np.where(times1 == tm)][0],lats2[np.where(times2 == tm)][0],\ + lons1[np.where(times1 == tm)][0],lons2[np.where(times2 == tm)][0],units='km') for tm in timesm] ) ) + + # And store the track id for the version 2 cyclone + ids2.append(i2) + + # If the version 1 track also existed last month, check last month's version 2 tracks, too... + if times1[0] < refday: + for i2b in range(len(trs2b)): + # Extract the observation times for the version 2b track + times2b = np.array(trs2b[i2b].data.time[trs2b[i2b].data.type != 0]) + # Assess the fraction of matching observations + matchfrac = 2*np.sum([t in times2b for t in times1]) / float(len(times1) + len(times2b)) + if matchfrac >= minmatch: + timesmb = [t for t in times1 if t in times2b] # Extract matched times + lats2b = np.array(trs2b[i2b].data.lat[trs2b[i2b].data.type != 0]) + lons2b = np.array(trs2b[i2b].data.lon[trs2b[i2b].data.type != 0]) + + # Calculate the mean separation between tracks + avgdist2b.append( np.mean( [haversine(lats1[np.where(times1 == tmb)][0],lats2b[np.where(times2b == tmb)][0],\ + lons1[np.where(times1 == tmb)][0],lons2b[np.where(times2b == tmb)][0],units='km') for tmb in timesmb] ) ) + + # And store the track id for the version 2b cyclone + ids2b.append(i2b) + + # Identify how many possible matches satisfy the maximum average separation distance + nummatch = np.where(np.array(avgdist2+avgdist2b) < maxsep)[0].shape[0] + + # Determine which version 2(b) track has the shortest average separation + if nummatch == 0: # If there's no match... + pdf = pdf.append(pd.DataFrame([{"Year1":date1[0],"Month1":date1[1],"tid1":trs1[i1].tid,"Num_Matches":nummatch,"Year2":np.nan,"Month2":np.nan,\ + "tid2":np.nan,"Dist":np.nan,"pcentDiff":np.nan,"areaDiff":np.nan,"depthDiff":np.nan,"dsqpDiff":np.nan},]), ignore_index=1, sort=1) + + elif np.min(avgdist2+[np.inf]) > np.min(avgdist2b+[np.inf]): # If the best match is from previous month... + im = ids2b[np.where(avgdist2b == np.min(avgdist2b))[0][0]] + timesmb = [t for t in times1 if t in np.array(trs2b[im].data.time[trs2b[im].data.type != 0])] # Extract matched times + + # Find average intensity differences + areaDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tmb,"area"]) - float(trs2b[im].data.loc[trs2b[im].data.time == tmb,"area"]) for tmb in timesmb]) + pcentDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tmb,"p_cent"]) - float(trs2b[im].data.loc[trs2b[im].data.time == tmb,"p_cent"]) for tmb in timesmb]) + dsqpDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tmb,"DsqP"]) - float(trs2b[im].data.loc[trs2b[im].data.time == tmb,"DsqP"]) for tmb in timesmb]) + depthDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tmb,"depth"]) - float(trs2b[im].data.loc[trs2b[im].data.time == tmb,"depth"]) for tmb in timesmb]) + + pdf = pdf.append(pd.DataFrame([{"Year1":date1[0],"Month1":date1[1],"tid1":trs1[i1].tid,"Num_Matches":nummatch,"Year2":timeb[0],"Month2":timeb[1],"tid2":trs2b[im].tid,\ + "Dist":np.min(avgdist2b),"pcentDiff":pcentDiff,"areaDiff":areaDiff,"depthDiff":depthDiff,"dsqpDiff":dsqpDiff},]), ignore_index=1, sort=1) + + else: # If the best match is from current month... + im = ids2[np.where(avgdist2 == np.min(avgdist2))[0][0]] + timesm = [t for t in times1 if t in np.array(trs2[im].data.time[trs2[im].data.type != 0])] # Extract matched times + + # Find average intensity differences + areaDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tm,"area"]) - float(trs2[im].data.loc[trs2[im].data.time == tm,"area"]) for tm in timesm]) + pcentDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tm,"p_cent"]) - float(trs2[im].data.loc[trs2[im].data.time == tm,"p_cent"]) for tm in timesm]) + dsqpDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tm,"DsqP"]) - float(trs2[im].data.loc[trs2[im].data.time == tm,"DsqP"]) for tm in timesm]) + depthDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tm,"depth"]) - float(trs2[im].data.loc[trs2[im].data.time == tm,"depth"]) for tm in timesm]) + + pdf = pdf.append(pd.DataFrame([{"Year1":date1[0],"Month1":date1[1],"tid1":trs1[i1].tid,"Num_Matches":nummatch,"Year2":date1[0],"Month2":date1[1],"tid2":trs2[im].tid,\ + "Dist":np.min(avgdist2),"pcentDiff":pcentDiff,"areaDiff":areaDiff,"depthDiff":depthDiff,"dsqpDiff":dsqpDiff},]), ignore_index=1, sort=1) + + # If the version 1 track only existed in the current month... + else: + # Identify how many possible matches satisfy the maximum average separation distance + nummatch = np.where(np.array(avgdist2) < maxsep)[0].shape[0] + + # Determine which version 2 track has the shortest average separation + if nummatch == 0: # If there's no match... + pdf = pdf.append(pd.DataFrame([{"Year1":date1[0],"Month1":date1[1],"tid1":trs1[i1].tid,"Num_Matches":nummatch,"Year2":np.nan,"Month2":np.nan,\ + "tid2":np.nan,"Dist":np.nan,"pcentDiff":np.nan,"areaDiff":np.nan,"depthDiff":np.nan,"dsqpDiff":np.nan},]), ignore_index=1, sort=1) + + else: # If the best match is from current month... + im = ids2[np.where(avgdist2 == np.min(avgdist2))[0][0]] + timesm = [t for t in times1 if t in np.array(trs2[im].data.time[trs2[im].data.type != 0])] # Extract matched times + + # Find average intensity differences + areaDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tm,"area"]) - float(trs2[im].data.loc[trs2[im].data.time == tm,"area"]) for tm in timesm]) + pcentDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tm,"p_cent"]) - float(trs2[im].data.loc[trs2[im].data.time == tm,"p_cent"]) for tm in timesm]) + dsqpDiff = np.nanmean([float(trs1[i1].data.loc[trs1[i1].data.time == tm,"DsqP"]) - float(trs2[im].data.loc[trs2[im].data.time == tm,"DsqP"]) for tm in timesm]) + depthDiff = np.mean([float(trs1[i1].data.loc[trs1[i1].data.time == tm,"depth"]) - float(trs2[im].data.loc[trs2[im].data.time == tm,"depth"]) for tm in timesm]) + + pdf = pdf.append(pd.DataFrame([{"Year1":date1[0],"Month1":date1[1],"tid1":trs1[i1].tid,"Num_Matches":nummatch,"Year2":date1[0],"Month2":date1[1],"tid2":trs2[im].tid,\ + "Dist":np.min(avgdist2),"pcentDiff":pcentDiff,"areaDiff":areaDiff,"depthDiff":depthDiff,"dsqpDiff":dsqpDiff},]), ignore_index=1, sort=1) + return pdf + + + +'''######################### +Read in a list of files from a directory +#########################''' +def listdir(path,start='',end='',contains=''): + ''' + Lists the files in a directory, subsets them based on their names, removes + hidden files, then sorts the remainder. Requires os module. + + Parameters + ---------- + path : string + The path to the directory in which your files exist. + start : string, optional + the file names must all start with this. The default is ''. + end : string, optional + The file names must all end with this. The default is ''. + contains : string, optional + The file names must all contain this. The default is ''. + + Note that using the default '' for all three optional parameters will + result in all files being kept except hideen files (those starting + with '.'). + + Returns + ------- + The sorted list of desired files in the given directory. + + ''' + + files = os.listdir(path) + files = [file for file in files if (file.startswith('.') == 0) and (contains in file) and file.startswith(start) and file.endswith(end)] + files.sort() + + return files \ No newline at end of file