diff --git a/README.md b/README.md index 937266f..1345f98 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,12 @@ This tool is developed as part of a [Python Toolbox](https://pro.arcgis.com/en/p ## Getting Started ### Prerequisites -The following is a list of the programs and libraries used in the tool, with their respective versions: -* ArcGIS Pro - ```arcpy``` (version 3.1) -* ```pandas``` (version 1.4) -* ```numpy``` (version 1.20) -* ```shapely``` (version 2.0) -* ```statsmodels``` (version 0.13) +Check that you have installed all the required libraries used in the toolbox. All packages with their tested versions are listed in [requirements.txt](https://github.com/AlbertGallegoJimenez/shoreline-evolution-tool/tree/main/requirements.txt). Note that when working with a cloned version of the ArcGIS ```anaconda``` environment there are already pre-installed libraries, those that are not and need to be installed manually are the following: + +* ```shapely``` +* ```statsmodels``` (from ArcGIS version 3.2 this package is included in the base ArcGIS ```anaconda``` environment) +* ```cartopy``` In terms of data, this tool relies on the use of the following two files: * **Baseline** (Vector - Polyline). This is the reference line used to assess the evolution of the coastal stretch. It can be digitized manually by the user with the help of a background orthophoto, it is recommended to place the baseline **inland**. The baseline must capture the general orientation of the coast. @@ -51,8 +50,8 @@ In terms of data, this tool relies on the use of the following two files: ### Installation -0. Make sure you have cloned the base ArcGIS' ```anaconda``` environment so you can install more packages. More info [here](https://pro.arcgis.com/en/pro-app/latest/arcpy/get-started/clone-an-environment.htm). -1. Install both ```shapely``` and ```statsmodels``` packages. +0. Make sure you have cloned the base ArcGIS ```anaconda``` environment so you can install more packages. More info [here](https://pro.arcgis.com/en/pro-app/latest/arcpy/get-started/clone-an-environment.htm). +1. Install ```shapely```, ```statsmodels``` and ```cartopy``` packages if you do not have it installed yet. 2. Download the content in the [src](https://github.com/AlbertGallegoJimenez/shoreline-evolution-tool/tree/main/src) folder. 3. Open the Catalog Pane in ArcGIS Pro and open the downloaded Toolbox (.pyt) to see the tools.
diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4e34f5d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +# Name Version +arcgispro (software) 3.1 +arcpy 3.1 +cartopy 0.21.1 +matplotlib 3.6.0 +numpy 1.20.1 +pandas 1.4.4 +regex 2022.7.9 +seaborn 0.12.1 +shapely 2.0.1 +statsmodels 0.13.5 \ No newline at end of file diff --git a/src/tools/correctTransects.py b/src/tools/correctTransects.py index de24aa9..c718fd9 100644 --- a/src/tools/correctTransects.py +++ b/src/tools/correctTransects.py @@ -74,7 +74,6 @@ def execute(self, parameters, messages): # Add Bearing attribute arcpy.management.CalculateGeometryAttributes(transectsFeature, "Bearing LINE_BEARING") - cursor = arcpy.da.SearchCursor(transectsFeature, ["transect_id", "Bearing"]) df = pd.DataFrame(data=[row for row in cursor], columns=["transect_id", "Bearing"]) @@ -87,7 +86,7 @@ def execute(self, parameters, messages): # Make the changes in the feature class RotateFeatures(df, transectsFeature) - # Recalulate the bearing with the transects inverted + # Recalculate the bearing with the transects inverted arcpy.management.CalculateGeometryAttributes(transectsFeature, "Bearing LINE_BEARING") cursor = arcpy.da.SearchCursor(transectsFeature, ["transect_id", "Bearing"]) df = pd.DataFrame(data=[row for row in cursor], columns=["transect_id", "Bearing"]) @@ -111,6 +110,7 @@ def execute(self, parameters, messages): def postExecute(self, parameters): """This method takes place after outputs are processed and added to the display.""" + # Update the labels of the transect feature aprx = arcpy.mp.ArcGISProject('CURRENT') aprxMap = aprx.activeMap transectsFeature = aprxMap.listLayers(os.path.basename(parameters[0].valueAsText))[0] diff --git a/src/tools/generateTransects.py b/src/tools/generateTransects.py index ca09340..ed2f7aa 100644 --- a/src/tools/generateTransects.py +++ b/src/tools/generateTransects.py @@ -108,6 +108,7 @@ def execute(self, parameters, messages): def postExecute(self, parameters): """This method takes place after outputs are processed and added to the display.""" + # Update the labels of the transect feature aprx = arcpy.mp.ArcGISProject('CURRENT') aprxMap = aprx.activeMap transectsFeature = aprxMap.listLayers(os.path.basename(parameters[0].valueAsText))[0] diff --git a/src/tools/performAnalysis.py b/src/tools/performAnalysis.py index fdfb923..432ad2e 100644 --- a/src/tools/performAnalysis.py +++ b/src/tools/performAnalysis.py @@ -81,11 +81,9 @@ def execute(self, parameters, messages): for field in metrics_fields: arcpy.management.AddField(transectsFeature, field, "DOUBLE") - count = 0 with arcpy.da.UpdateCursor(transectsFeature, metrics_fields) as cursor: - for row in cursor: - cursor.updateRow(shore_metrics.loc[count, metrics_fields].tolist()) - count += 1 + for i, _ in enumerate(cursor): + cursor.updateRow(shore_metrics.loc[i, metrics_fields].tolist()) return @@ -102,8 +100,6 @@ def postExecute(self, parameters): # Get the current symbology settings of the layer sym = transectsLayerObj.symbology - # Get the layer's CIM definition - cim = transectsLayerObj.getDefinition('V3') # Set the renderer to Graduated Colors sym.updateRenderer('GraduatedColorsRenderer') @@ -113,7 +109,6 @@ def postExecute(self, parameters): # Specify the field used for classification sym.renderer.classificationField = 'LRR' - cim.renderer.heading = 'LRR (m/year)' # Set the number of class breaks sym.renderer.breakCount = 6 @@ -125,7 +120,7 @@ def postExecute(self, parameters): labels = ["-50.0 - -4.0", "-4.0 - -2.0", "-2.0 - 0.0", "0.0 - 2.0", "2.0 - 4.0", "4.0 - 50.0"] # Define sizes for each class - sizes = [4, 2, 1, 1, 2, 4] + sizes = [6, 3, 1.5, 1.5, 3, 6] # Update values for each class for i, brk in enumerate(sym.renderer.classBreaks): @@ -135,6 +130,21 @@ def postExecute(self, parameters): # Apply the updated symbology settings to the layer transectsLayerObj.symbology = sym - transectsLayerObj.setDefinition(cim) + # Get the layer's CIM definition + cim = transectsLayerObj.getDefinition('V3') + + # Set the label for the symbolised field + cim.renderer.heading = 'LRR (m/year)' + + # Exclude non-significant transects and apply unique symbology + cim.renderer.useExclusionSymbol = True + cim.renderer.exclusionClause = 'Pvalue > 0.05' + cim.renderer.exclusionLabel = 'Non-significant transect' + cim.renderer.exclusionSymbol.symbol.symbolLayers[0].color.values = [130, 130, 130, 100] + cim.renderer.exclusionSymbol.symbol.symbolLayers[0].width = 1.5 + + # Update the CIM + transectsLayerObj.setDefinition(cim) + return \ No newline at end of file diff --git a/src/tools/plotResults.py b/src/tools/plotResults.py new file mode 100644 index 0000000..b695d67 --- /dev/null +++ b/src/tools/plotResults.py @@ -0,0 +1,122 @@ +import arcpy +from tools.utils.plot_results import PlottingUtils + +class PlotResults(object): + def __init__(self): + """Define the tool (tool name is the name of the class).""" + self.label = "5. Plot The Analysis Results" + self.description = "" + self.canRunInBackground = False + + def getParameterInfo(self): + """Define parameter definitions""" + + shoreline_param = arcpy.Parameter( + displayName="Input Shorelines Intersection Points Feature", + name="shore_features", + datatype="GPFeatureLayer", + parameterType="Required", + direction="Input") + shoreline_param.filter.list = ["Point"] + + transects_param = arcpy.Parameter( + displayName="Input Transects Feature", + name="transects_features", + datatype="GPFeatureLayer", + parameterType="Required", + direction="Input") + transects_param.filter.list = ["Polyline"] + + transects_ID_2plot_param = arcpy.Parameter( + displayName="Input Transects ID To Plot", + name="transects_ID_2plot", + datatype="GPValueTable", + parameterType="Required", + direction="Input") + transects_ID_2plot_param.columns = [['GPLong', 'Transects ID']] + + parameters = [shoreline_param, transects_param, transects_ID_2plot_param] + + return parameters + + def isLicensed(self): + """Set whether tool is licensed to execute.""" + return True + + def updateParameters(self, parameters): + """Modify the values and properties of parameters before internal + validation is performed. This method is called whenever a parameter + has been changed.""" + return + + def updateMessages(self, parameters): + """Modify the messages created by internal validation for each tool + parameter. This method is called after internal validation.""" + parameters[2].setWarningMessage( + "Check significance before selecting transects (Pvalue < 0.05).") + return + + def execute(self, parameters, messages): + """The source code of the tool.""" + shoreFeatures = parameters[0].valueAsText + transectsFeature = parameters[1].valueAsText + transectsID_2plot = parameters[2].value + transectsID_2plot = [id[0] for id in transectsID_2plot] # As the parameter value is passed as a list of lists, instead of a list of integers + + # Check for errors in the transect IDs selected + self._check_transects_id(transectsID_2plot, transectsFeature) + + # Initialize the class + plotter = PlottingUtils(transects=transectsFeature, + shore_intersections=shoreFeatures) + + # Plot the spatial evolution + plotter.plot_spatial_evolution() + + # Plot the time series for the transects selected + plotter.plot_time_series(transects2plot=transectsID_2plot) + + # Plot the seasonality for the transects selected. Set a minimum of 2 years of data to plot the seasonality. + if plotter.shore_intersections_df['date'].dt.year.max() - plotter.shore_intersections_df['date'].dt.year.min() >= 2: + plotter.plot_seasonality(transects2plot=transectsID_2plot) + + # Plot the LRR map + plotter.plot_map('LRR') + + # Plot the SCE map + plotter.plot_map('SCE') + + # Plot the NSM map + plotter.plot_map('NSM') + + return + + def postExecute(self, parameters): + """This method takes place after outputs are processed and + added to the display.""" + return + + def _check_transects_id(self, transectsID_2plot, transectsFeature): + """This private method checks that all transects + passed by the user are valid.""" + + # Check that all IDs are numerical. This code may be unnecessary since the parameter is defined as 'GPLong' and this means that ArcGIS will only accept numerical values. + if not all(isinstance(id, (int, float)) for id in transectsID_2plot): + arcpy.AddError('Invalid transect ID, check that all IDs are numeric.') + raise Exception('Invalid transect ID, check that all IDs are numeric.') + + # Check for duplicates + if len(transectsID_2plot) != len(set(transectsID_2plot)): + arcpy.AddError('Some transects are repeated.') + raise Exception('Some transects are repeated.') + + # Check if the user only has selected one transect + if len(transectsID_2plot) == 1: + arcpy.AddError('Please, select more than one transect to plot.') + raise Exception('Please, select more than one transect to plot.') + + # Check for ID out of range + list_transect_id = [row[0] for row in arcpy.da.SearchCursor(transectsFeature, ['transect_id'])] + if set(transectsID_2plot) - set(list_transect_id): + arcpy.AddError('Invalid transect ID, check that all IDs are within the valid range.') + raise Exception('Invalid transect ID, check that all IDs are within the valid range.') diff --git a/src/tools/utils/__pycache__/intersect_lines.cpython-39.pyc b/src/tools/utils/__pycache__/intersect_lines.cpython-39.pyc index cf46271..11a22c9 100644 Binary files a/src/tools/utils/__pycache__/intersect_lines.cpython-39.pyc and b/src/tools/utils/__pycache__/intersect_lines.cpython-39.pyc differ diff --git a/src/tools/utils/__pycache__/plot_results.cpython-39.pyc b/src/tools/utils/__pycache__/plot_results.cpython-39.pyc new file mode 100644 index 0000000..f0f12c5 Binary files /dev/null and b/src/tools/utils/__pycache__/plot_results.cpython-39.pyc differ diff --git a/src/tools/utils/__pycache__/shoreline_evolution.cpython-39.pyc b/src/tools/utils/__pycache__/shoreline_evolution.cpython-39.pyc index 7b91ffa..e230549 100644 Binary files a/src/tools/utils/__pycache__/shoreline_evolution.cpython-39.pyc and b/src/tools/utils/__pycache__/shoreline_evolution.cpython-39.pyc differ diff --git a/src/tools/utils/__pycache__/transect_processor.cpython-39.pyc b/src/tools/utils/__pycache__/transect_processor.cpython-39.pyc index b4739e0..3dbc1ea 100644 Binary files a/src/tools/utils/__pycache__/transect_processor.cpython-39.pyc and b/src/tools/utils/__pycache__/transect_processor.cpython-39.pyc differ diff --git a/src/tools/utils/plot_results.py b/src/tools/utils/plot_results.py new file mode 100644 index 0000000..4b60cd1 --- /dev/null +++ b/src/tools/utils/plot_results.py @@ -0,0 +1,316 @@ +import arcpy +import seaborn as sns +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import os +import re +import cartopy.crs as ccrs +from tools.utils.intersect_lines import * +from matplotlib.colors import Normalize, TwoSlopeNorm +from matplotlib.cm import ScalarMappable +import matplotlib.lines as mlines +from matplotlib.gridspec import GridSpec + +# Define the settings for the plots +plt.style.use('classic') +plt.rcParams['axes.grid'] = True +plt.rcParams['grid.linestyle'] = '--' +plt.rcParams['grid.linewidth'] = 0.5 +plt.rcParams['axes.prop_cycle'] = plt.cycler(color=['#5B9CFD', '#FECB31', '#bfbbd9', '#fa8174', '#81b1d2', + '#fdb462', '#b3de69', '#bc82bd', '#ccebc4', '#ffed6f']) + +class PlottingUtils(): + def __init__(self, transects, shore_intersections): + """ + Constructor method for initializing PlottingUtils class. + + Parameters: + transects (arcpy.FeatureClass): Feature Class object for transects (Line geometries). + shore_intersections (arcpy.FeatureClass): Feature Class object for shoreline intersections (Point geometries). + """ + self.transects = transects + + # Set the directory where plots will be stored + aprx = arcpy.mp.ArcGISProject('CURRENT') + self.out_dir = os.path.join(aprx.homeFolder, 'Plots results') + if not os.path.exists(self.out_dir): + os.mkdir(self.out_dir) + + # Convert Feature Classes to Pandas DataFrames + self.transects_df = self._feature_class_to_dataframe(transects) + self.shore_intersections_df = self._feature_class_to_dataframe(shore_intersections) + self.shore_intersections_df['date'] = pd.to_datetime(self.shore_intersections_df['date']) + + # Create Shapely Object for transects geometries + self.transects_shapely = line_arcgis2shapely(feature=transects, id='transect_id') + + + def _feature_class_to_dataframe(self, feature_class): + """ + Private method to convert an ArcGIS Feature Class to a Pandas DataFrame. + + Parameters: + feature_class (arcpy.FeatureClass): Feature Class object. + + Returns: + pd.DataFrame: Pandas DataFrame containing feature class data. + """ + fields = [field.name for field in arcpy.ListFields(feature_class)] + + return pd.DataFrame([row for row in arcpy.da.SearchCursor(feature_class, fields)], columns=fields) + + + def _get_UTM_projection(self): + """ + Private method to get UTM projection from transects Feature Class. + + Returns: + UTM_number (int): UTM number. + southern_hemisphere (bool): Whether or not the Feature Class is in the southern hemisphere. + """ + # CRS string (e.g. 'WGS_1984_UTM_Zone_19N') + crs = arcpy.Describe(self.transects).SpatialReference.name + + # Regex codes to extract the UTM number and the hemisphere + num_code = r'\b(\d+)[A-Za-z]?\b' + hemisphere_code = r'\b\d+([A-Za-z])\b' + + UTM_number = int(re.findall(num_code, crs.split('_')[-1])[0]) + hemisphere_UTM = re.findall(hemisphere_code, crs.split('_')[-1])[0] + + if hemisphere_UTM == 'N': + southern_hemisphere = False + else: + southern_hemisphere = True + + return UTM_number, southern_hemisphere + + + def _set_map_configuration(self, metric): + """ + Private method to set the map configuration for plotting LRR, SCE and NSM. + That is, the colormap, the type of normalization scale and the type of colorbar according to the metric. + + Parameters: + metric (string): Name of the feature to plot. + + Returns: + cmap (matplotlib.colors.LinearSegmentedColormap): The colormap. + norm (matplotlib.colors.TwoSlopeNorm or matplotlib.colors.Normalize): The type of the normalization. + extend_cbar (string): The type of the colorbar according to cmap and norm. + """ + + metric_min, metric_max = self.transects_df[metric].describe()[['min', 'max']] + + if metric_min < 0 and metric_max > 0: + cmap = plt.get_cmap('RdBu') + norm = TwoSlopeNorm(vmin=metric_min, vcenter=0, vmax=metric_max) + extend_cbar = 'both' + elif metric_min < 0 and metric_max < 0: + cmap = plt.get_cmap('Reds') + norm = Normalize(vmin=metric_min, vmax=0) + extend_cbar = 'min' + elif metric_min > 0 and metric_max > 0: + cmap = plt.get_cmap('Blues') + norm = Normalize(vmin=0, vmax=metric_max) + extend_cbar = 'max' + + return cmap, norm, extend_cbar + + # ===== FROM HERE, ALL THE FUNCTIONS TO CREATE THE PLOTS ARE DEFINED ===== + + def plot_spatial_evolution(self): + # Plot the distances between all shorelines and the baseline on each transect + + fig, ax = plt.subplots(figsize=(12, 5)) + + # Plot all values as scatter + ax.scatter(self.shore_intersections_df['transect_id'], + self.shore_intersections_df['distance_from_base'], + alpha=.1, lw=0, zorder=1) + + # Plot average width value for each transect + ax.plot(self.shore_intersections_df['transect_id'].unique(), + self.shore_intersections_df.groupby('transect_id')['distance_from_base'].mean(), + label='avg', color='#fa8174', lw=2, zorder=2) + + # Plot mean +-2*std width value for each transect + ax.fill_between(self.shore_intersections_df['transect_id'].unique(), + self.shore_intersections_df.groupby('transect_id')['distance_from_base'].mean()+\ + 2 * self.shore_intersections_df.groupby('transect_id')['distance_from_base'].std(), + self.shore_intersections_df.groupby('transect_id')['distance_from_base'].mean()-\ + 2 * self.shore_intersections_df.groupby('transect_id')['distance_from_base'].std(), + color='#bfbbd9', alpha=.5, lw=0, label='avg\u00B12std', zorder=0) + + # Plot settings + ax.set_xticks((np.arange(0, max(self.shore_intersections_df['transect_id']) + 2, 2)).tolist()) + ax.set_xlabel('transect_id') + ax.set_ylabel('distance from baseline (m)') + ax.set_xlim([-1, max(self.shore_intersections_df['transect_id']) + 2]) + plt.text(-0.05, 0.9, 'seaward', transform=plt.gca().transAxes, horizontalalignment='right', fontstyle='italic') + plt.text(-0.05, .1, 'landward', transform=plt.gca().transAxes, horizontalalignment='right', fontstyle='italic') + plt.grid(linestyle='--', alpha=0.3) + ax.legend() + ax.set_title('Spatial shoreline evolution (%.f - %.f)' % (self.shore_intersections_df['date'].min().year, + self.shore_intersections_df['date'].max().year)) + fig.savefig(os.path.join(self.out_dir, 'Spatial shoreline evolution.png'), dpi=300, bbox_inches='tight') + + + def plot_time_series(self, transects2plot): + # Plot time series for selected transects + + fig = plt.figure(figsize=(12, 2 * len(transects2plot))) + gs = GridSpec(len(transects2plot), 7, figure=fig) + + for i, t in enumerate(transects2plot): + ax = fig.add_subplot(gs[i, :-1]) + + # Prepare the data to plot + data_transect = self.shore_intersections_df.loc[self.shore_intersections_df['transect_id'] == t, :] + data_transect.index = pd.to_datetime(data_transect['date']) + data_transect = data_transect.sort_index() + data_transect['Time'] = np.arange(len(data_transect.index)) + X = data_transect.index.map(pd.Timestamp.toordinal) # features + y = data_transect.loc[:, 'distance_from_base'] # target + + # Plot time series + #y.plot(ax=ax, color='#5B9CFD', linestyle='-', marker='o', markersize=2, label='shoreline positions') + ax.plot(X, y.values, linestyle='-', marker='o', markersize=2, label='shoreline positions') + sns.regplot(x=X, y=y, ci=95, scatter=False, label='linear regression fit', ax=ax) + + # Plot settings + ax.set_ylabel('distance from\nbaseline (m)') + ax.locator_params(axis='y', nbins=4) + ax.set_title('transect ' + str(t)) + ax.grid(alpha=0.3) + + # Plot LRR error plot + ax2 = fig.add_subplot(gs[i, -1:]) + lrr = self.transects_df.loc[self.transects_df['transect_id']==t, 'LRR'] + lci = self.transects_df.loc[self.transects_df['transect_id']==t, ['LCI_low', 'LCI_upp']].to_numpy()[0] + ax2.errorbar(0, lrr, yerr=([abs(lci[0] - lrr.values[0])], [lci[1] - lrr.values[0]]), + fmt='or', markersize=8, capsize=5) + # Plot settings + ax2.set_xticklabels([]) + ax2.locator_params(axis='y', nbins=4) + ax2.locator_params(axis='x', nbins=1) + ax2.grid(axis='y', alpha=0.3) + ax2.grid(axis='x', alpha=0) + + if i == len(transects2plot) - 1: + # Time series plot + ax.set_xlabel('') + xticks = ax.get_xticks() + labels = [pd.Timestamp.fromordinal(int(label)).date().strftime("%m-%Y") for label in xticks] + ax.set_xticks(xticks) + ax.set_xticklabels(labels) + ax.legend(fontsize=8) + + # LRR error plot + ax2.set_xlabel('LRR\u00B195%\n(m/year)') + + else: + # Time series plot + ax.set_xlabel('') + ax.set_xticklabels([]) + + plt.subplots_adjust(hspace=0.4, wspace=1) + + fig.savefig(os.path.join(self.out_dir, 'Time shoreline evolution.png'), bbox_inches='tight', dpi=300) + + + def plot_seasonality(self, transects2plot): + # Boxplot by months for selected transects + + shore_month = self.shore_intersections_df.copy() + shore_month['month'] = shore_month['date'].dt.month + + fig, ax = plt.subplots(len(transects2plot), 1, figsize=(10, 15), sharex=True) + + for i, t in enumerate(transects2plot): + # Grab the data + data = shore_month.loc[shore_month['transect_id']==t, ['month', 'distance_from_base']] + + # Median values Line plot + ax[i].plot(data.groupby('month')['distance_from_base'].median(), + color='#FECB31', marker='o', markeredgecolor=None, alpha=.7) + + # Box plot + medianprops = dict(linestyle='-', linewidth=2, color='#FECB31') + whiskerprops = dict(linestyle='-', color='#5B9CFD') + data.boxplot(column='distance_from_base', by='month', ax=ax[i], medianprops=medianprops, whiskerprops=whiskerprops) + + # Plot settings + ax[i].set_xlabel('') + ax[i].set_ylabel('distance from\nbaseline (m)') + ax[i].locator_params(axis='y', nbins=5) + ax[i].set_title('transect ' + str(t)) + ax[i].grid(linestyle='--', alpha=0.3) + ax[i].grid(axis='x', alpha=0) + + if i == len(transects2plot) - 1: + ax[i].set_xticklabels(['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] * len(transects2plot)) + + fig.suptitle(None) + plt.subplots_adjust(hspace=0.2) + fig.savefig(os.path.join(self.out_dir, 'Shoreline evolution seasonality.png'), dpi=300, bbox_inches='tight') + + + def plot_map(self, metric): + # Plot a map of the selected metric (LRR, SCE or NSM) + + # Set the cmap, norm and the type of cbar of the plot + cmap, norm, extend_cbar = self._set_map_configuration(metric) + + # Set projection parameter + UTM_number, southern_hemisphere = self._get_UTM_projection() + proj = ccrs.UTM(UTM_number, southern_hemisphere=southern_hemisphere) + + # Create a subplot with UTM projection + fig, ax = plt.subplots(layout='compressed', subplot_kw={'projection':proj}) + + # Iterate through transects and plot lines + for i, t in self.transects_shapely.items(): + + # Check if Pvalue is less than 0.05 for significance + if self.transects_df.loc[self.transects_df['transect_id'] == i, 'Pvalue'].values <= 0.05: + color = cmap(norm(self.transects_df.loc[self.transects_df['transect_id'] == i, metric])) + ls = '-' + else: + color = 'gray' + ls = '--' + + # Plot the transect line + ax.plot(*t.xy, + color=color, + transform=proj, + lw=3, + ls=ls) + + # Create a legend entry for non-significant transects + legend_entry = mlines.Line2D([], [], color='gray', lw=2, ls='--', label='Non-significant transect') + + # Customize gridlines, ticks, and appearance + ax.gridlines(crs=proj, linewidth=1, color='black', alpha=0, linestyle="--") + ax.set_xticks(ax.get_xticks(), crs=proj) + ax.set_yticks(ax.get_yticks(), crs=proj) + ax.grid(alpha=.3) + + # Add colorbar and set title + if self.transects_df['Pvalue'].min() <= 0.05: + fig.colorbar(ScalarMappable(norm=norm, cmap=cmap), extend=extend_cbar, ax=ax) + if metric == 'LRR': + ax.set_title('Linear Regression Rate, LRR (m/year)', y=1.05) + elif metric == 'SCE': + ax.set_title('Shoreline Change Envelope, SCE (m)', y=1.05) + elif metric == 'NSM': + ax.set_title('Net Shoreline Movement, NSM (m)', y=1.05) + + # Set limits, labels, legend, and save the figure + lons = [x for t in self.transects_shapely.values() for x in t.xy[0]] # Extract the longitudes for plotting purposes (Sometimes the plot is centered to the west and a blank space is left to the east of the study area) + ax.set_xlim([ax.get_xlim()[0], max(lons)]) + ax.set_xlabel('Eastings (m)') + ax.set_ylabel('Northings (m)') + ax.legend(handles=[legend_entry], fontsize='small') + fig.savefig(os.path.join(self.out_dir, '{0}_transects.png'.format(metric)), dpi=300, bbox_inches='tight') diff --git a/src/tools/utils/transect_processor.py b/src/tools/utils/transect_processor.py index d8de6da..ae487d1 100644 --- a/src/tools/utils/transect_processor.py +++ b/src/tools/utils/transect_processor.py @@ -17,6 +17,7 @@ def invert_angles(self): # Detect the first transects that are inverted (if there are more than one change). To do so, find a difference angle around ~180ยบ (it has been selected a range of 180 +-50). start_change_transects = self.df[(self.df['diffBear'].abs() >= 130) & (self.df['diffBear'].abs() < 230)]['transect_id'].to_list() + # Empty list of the transects that need to be inverted transects2correct = [] for i, _ in enumerate(start_change_transects): if (i + 1) % 2 != 0: # Odd number @@ -30,7 +31,7 @@ def invert_angles(self): pass self.df['Angle'] = 0 - self.df.loc[self.df['transect_id'].isin(transects2correct), 'Angle'] = 180 # Rotate 180 degrees the bearing anle + self.df.loc[self.df['transect_id'].isin(transects2correct), 'Angle'] = 180 # Rotate 180 degrees the bearing angle def classify_transects(self): # Classify transects with large differences using the corrFactor value. In addition, try not to take into account the differences in the 360-0 sector. @@ -49,11 +50,9 @@ def __init__(self, df, fclass): # Initialize the class by adding an angle field with the values calculated above arcpy.management.AddField(fclass, 'Angle', 'DOUBLE') - count = 0 with arcpy.da.UpdateCursor(fclass, 'Angle') as cursor: - for row in cursor: - cursor.updateRow([df.loc[count, 'Angle']]) - count += 1 + for i, row in enumerate(cursor): + cursor.updateRow([df.loc[i, 'Angle']]) # Rebuild each polyine with rotated vertices with arcpy.da.UpdateCursor(fclass, ['SHAPE@', 'Angle']) as cursor: