From c35587429f18f6de3c7b4f27e66fa97077040927 Mon Sep 17 00:00:00 2001 From: Julien Emile-Geay Date: Thu, 27 Jun 2024 11:28:03 -0700 Subject: [PATCH] Archive graphics (#593) * candidate colors/shapes * map_neighbors no longer assumes the first neighbor in the list is itself map_neighbors now checks for itself and removes duplicate rows in the neighbors dataframe, keeping the final row as the target * tweaking colors * implemented map_neighbors() title with CI test * map title + CI tests --------- Co-authored-by: Jordan Landers --- pyleoclim/core/geoseries.py | 87 ++++++++++++++++++++++---- pyleoclim/tests/test_core_GeoSeries.py | 53 +++++++++++++++- pyleoclim/utils/lipdutils.py | 62 ++++++++++++------ pyleoclim/utils/mapping.py | 11 +++- 4 files changed, 177 insertions(+), 36 deletions(-) diff --git a/pyleoclim/core/geoseries.py b/pyleoclim/core/geoseries.py index 40163bbd..b84bf2f2 100644 --- a/pyleoclim/core/geoseries.py +++ b/pyleoclim/core/geoseries.py @@ -296,7 +296,7 @@ def from_Series(lat, lon, elevation=None,sensorType=None,observationType=None, def map(self, projection='Orthographic', proj_default=True, background=True, borders=False, coastline=True, rivers=False, lakes=False, ocean=True, - land=True, fig=None, gridspec_slot=None, + land=True, fig=None, gridspec_slot=None, title = None, figsize=None, marker='archiveType', hue='archiveType', size=None, edgecolor='w', markersize=None, scatter_kwargs=None, cmap=None, colorbar=False, gridspec_kwargs=None, legend=True, lgd_kwargs=None, savefig_settings=None): @@ -388,17 +388,36 @@ def map(self, projection='Orthographic', proj_default=True, .. jupyter-execute:: - import pyleoclim as pyleo ts = pyleo.utils.datasets.load_dataset('EDC-dD') fig, ax = ts.map() + + By default, the figure has no title. For a title built from the available labels: + + .. jupyter-execute:: + + fig, ax = ts.map(title=True) + + For a custom title, and custom projection: + + .. jupyter-execute:: + + fig, ax = ts.map(title='Insert title here', projection='RotatedPole', + proj_default={'pole_longitude':0.0, 'pole_latitude':-90.0, 'central_rotated_longitude':45.0}) ''' if markersize != None: scatter_kwargs['markersize'] = markersize + + if type(title)==bool: + if title == False: + title = None + else: + if self.label is not None: + title = f"{self.label} location" fig, ax_d = mapping.scatter_map(self, hue=hue, size=size, marker=marker, projection=projection, - proj_default=proj_default, + proj_default=proj_default, title = title, background=background, borders=borders, rivers=rivers, lakes=lakes, ocean=ocean, land=land, coastline=coastline, figsize=figsize, scatter_kwargs=scatter_kwargs, gridspec_kwargs=gridspec_kwargs, @@ -409,7 +428,7 @@ def map(self, projection='Orthographic', proj_default=True, def map_neighbors(self, mgs, radius=3000, projection='Orthographic', proj_default=True, background=True, borders=False, rivers=False, lakes=False, ocean=True, - land=True, fig=None, gridspec_slot=None, + land=True, fig=None, gridspec_slot=None, title = None, figsize=None, marker='archiveType', hue='archiveType', size=None, edgecolor=None,#'w', markersize=None, scatter_kwargs=None, cmap=None, colorbar=False, gridspec_kwargs=None, legend=True, lgd_kwargs=None, savefig_settings=None): @@ -470,6 +489,10 @@ def map_neighbors(self, mgs, radius=3000, projection='Orthographic', proj_defaul - "path" must be specified; it can be any existed or non-existed path, with or without a suffix; if the suffix is not given in "path", it will follow "format" - "format" can be one of {"pdf", "eps", "png", "ps"}. The default is None. + + title : bool or str + the title for the figure. If True or None, made automatically from the objects' labels. + Set to False for an empty title. Returns ------- @@ -500,10 +523,23 @@ def map_neighbors(self, mgs, radius=3000, projection='Orthographic', proj_defaul archiveType = row['archiveType'], verbose = False, label=row['dataSetName']+'_'+row['paleoData_variableName'])) - mgs = pyleo.MultipleGeoSeries(series_list=ts_list,time_unit='years AD') + mgs = pyleo.MultipleGeoSeries(series_list=ts_list,time_unit='years AD',label='Euro2k') gs = ts_list[6] # extract one record as the target one gs.map_neighbors(mgs, radius=4000) + By default, the figure has no title. For a title built from the available labels: + + .. jupyter-execute:: + + gs.map_neighbors(mgs, radius=4000, title=True) + + For a custom title: + + .. jupyter-execute:: + + gs.map_neighbors(mgs, radius=4000, title='Insert title here') + + ''' from ..core.multiplegeoseries import MultipleGeoSeries if markersize != None: @@ -512,42 +548,69 @@ def map_neighbors(self, mgs, radius=3000, projection='Orthographic', proj_defaul # find neighbors lats = [ts.lat for ts in mgs.series_list] lons = [ts.lon for ts in mgs.series_list] + # compute distance from self to all points in supplied catalog dist = mapping.compute_dist(self.lat, self.lon, lats, lons) + # identify indices of neighbors within specified radius neigh_idx = mapping.within_distance(dist, radius) - neighbors =[mgs.series_list[i] for i in neigh_idx if i !=0] + # create a new MultipleGeoSeries object with only the neighbors + neighbors =[mgs.series_list[i] for i in neigh_idx] + dists = [dist[i] for i in neigh_idx] neighbors = MultipleGeoSeries(neighbors) + # create a dataframe for neighbors and add distance to target df = mapping.make_df(neighbors, hue=hue, marker=marker, size=size) + df['distance'] = dists + + # create a dataframe for the target df_self = mapping.make_df(self, hue=hue, marker=marker, size=size) + df_self['distance'] = 0 neighborhood = pd.concat([df, df_self], axis=0) - # additional columns are added manually - neighbor_coloring = ['w' for ik in range(len(neighborhood))] + # remove duplicates + # keep = "last" is specified to make sure that if the target was included + # in the potential neighbor catalog, it is kept as the last record + neighborhood = neighborhood.drop_duplicates(keep='last') + # add a column to specify the status of the record as either neighbor or target neighbor_status = ['neighbor' for ik in range(len(neighborhood))] neighbor_status[-1] = 'target' neighborhood['neighbor_status'] = neighbor_status + # neighbors are assigned white as edgecolor + neighbor_coloring = ['w' for ik in range(len(neighborhood))] + + # if edgecolor is not specified, use black, otherwise, use the specified color if edgecolor is None: edgecolor = 'k' if isinstance(scatter_kwargs, dict): edgecolor = scatter_kwargs.pop('edgecolor', 'k') neighbor_coloring[-1] = edgecolor - neighborhood['original'] =neighbor_coloring + neighborhood['edgecolor'] =neighbor_coloring - # plot neighbors + if type(title)==bool: + if title == False: + title = None + else: + if mgs.label is not None and self.label is not None: + title = f"{mgs.label} neighbors for {self.label} within {radius} km" + + # plot neighbors + # in future version, if edgecolor is specified as a dictionary with keys "neighbor" and "target", + # and values that are colors, that mapping will be used to color the edges of the points fig, ax_d = mapping.scatter_map(neighborhood, fig=fig, gs_slot=gridspec_slot, hue=hue, size=size, marker=marker, projection=projection, proj_default=proj_default, background=background, borders=borders, rivers=rivers, lakes=lakes, - ocean=ocean, land=land, + ocean=ocean, land=land, title = title, figsize=figsize, scatter_kwargs=scatter_kwargs, lgd_kwargs=lgd_kwargs, gridspec_kwargs=gridspec_kwargs, colorbar=colorbar, - legend=legend, cmap=cmap, edgecolor=neighborhood['original'].values) + legend=legend, cmap=cmap, edgecolor=neighborhood['edgecolor'].values) + + return fig, ax_d def dashboard(self, figsize=[11, 8], gs=None, plt_kwargs=None, histplt_kwargs=None, spectral_kwargs=None, diff --git a/pyleoclim/tests/test_core_GeoSeries.py b/pyleoclim/tests/test_core_GeoSeries.py index 87594d4e..9b4d3663 100644 --- a/pyleoclim/tests/test_core_GeoSeries.py +++ b/pyleoclim/tests/test_core_GeoSeries.py @@ -86,7 +86,7 @@ def test_init_dropna(self, evenly_spaced_series): print(ts2.value) assert ~np.isnan(ts2.value[0]) -@pytest.mark.xfail # will fail until pandas is fixed +#@pytest.mark.xfail # will fail until pandas is fixed class TestUIGeoSeriesResample(): ''' test GeoSeries.Resample() ''' @@ -111,8 +111,42 @@ def test_map_neighbors_t1(self, pinkgeoseries): mgs = multiple_pinkgeoseries() fig, ax = ts.map_neighbors(mgs, radius=5000) pyleo.closefig(fig) + + @pytest.mark.parametrize('title',[None, False, True, 'Untitled']) + def test_map_neighbors_t2(self, title): + PLOT_DEFAULT = pyleo.utils.lipdutils.PLOT_DEFAULT + ntypes = len(PLOT_DEFAULT) + + lat = np.random.uniform(20,70,ntypes) + lon = np.random.uniform(-20,60,ntypes) + + dummy = [1, 2, 3] -class TestUiGeoSeriesMap(): + ts = pyleo.GeoSeries(time = dummy, value=dummy, lat=lat.mean(), lon=lon.mean(), + auto_time_params=True, verbose=False, archiveType='Wood', + label='Random Tree') + series_list = [] + for i, key in enumerate(PLOT_DEFAULT.keys()): + ser = ts.copy() + ser.lat=lat[i] + ser.lon=lon[i] + ser.archiveType=key + ser.label=key + series_list.append(ser) + + mgs = pyleo.MultipleGeoSeries(series_list,time_unit='Years CE', label = 'multi-archive maelstrom') + + fig, ax = ts.map_neighbors(mgs,radius=5000, title = title) + + if title is None or title == False: + assert ax['map'].get_title() == '' + elif title == True: + assert ax['map'].get_title() == 'multi-archive maelstrom neighbors for Random Tree within 5000 km' + else: + ax['map'].get_title() == 'Untitled' + pyleo.closefig(fig) + +class TestUIGeoSeriesMap(): ''' test GeoSeries.map() ''' @@ -121,6 +155,19 @@ def test_map_t0(self, pinkgeoseries): fig, ax = ts.map() pyleo.closefig(fig) + @pytest.mark.parametrize('title',[None, False, True, 'Untitled']) + def test_map_t1(self, pinkgeoseries, title): + ts = pinkgeoseries + fig, ax = ts.map(title=title) + if title is None or title == False: + assert ax['map'].get_title() == '' + elif title == True: + assert ax['map'].get_title() == 'pink noise geoseries location' + else: + ax['map'].get_title() == 'Untitled' + pyleo.closefig(fig) + pyleo.closefig(fig) + def test_segment(): ''' @@ -133,7 +180,7 @@ def test_segment(): assert np.array_equal(mgs.series_list[0].value,gs.value[:4000]) assert np.array_equal(mgs.series_list[1].value,gs.value[5000:]) -class TestUiGeoSeriesDashboard(): +class TestUIGeoSeriesDashboard(): ''' test GeoSeries.Dashboard ''' diff --git a/pyleoclim/utils/lipdutils.py b/pyleoclim/utils/lipdutils.py index cd77b8c4..a010a885 100644 --- a/pyleoclim/utils/lipdutils.py +++ b/pyleoclim/utils/lipdutils.py @@ -21,27 +21,53 @@ def __setitem__(self, key, value): def __getitem__(self, key): return super().__getitem__(key.lower().replace(" ", "")) -PLOT_DEFAULT = {'GroundIce': ['#86CDFA', 'h'], - 'Borehole': ['#00008b', 'h'], - 'Coral': ['#FF8B00', 'o'], - 'Documents': ['#f8d568', 'p'], - 'GlacierIce': ['#86CDFA', 'd'], - 'Hybrid': ['#808000', '*'], - 'LakeSediment': ['#8A4513', '^'], - 'MarineSediment': ['#8A4513', 's'], - 'Sclerosponge': ['r', 'o'], +# Old one: +# PLOT_DEFAULT = {'GroundIce': ['#86CDFA', 'h'], +# 'Borehole': ['#00008b', 'h'], +# 'Coral': ['#FF8B00', 'o'], +# 'Documents': ['#f8d568', 'p'], +# 'GlacierIce': ['#86CDFA', 'd'], +# 'Hybrid': ['#808000', '*'], +# 'LakeSediment': ['#8A4513', '^'], +# 'MarineSediment': ['#8A4513', 's'], +# 'Sclerosponge': ['r', 'o'], +# 'Speleothem': ['#FF1492', 'd'], +# 'Wood': ['#32CC32', '^'], +# 'MolluskShell': ['#FFD600', 'h'], +# 'Peat': ['#2F4F4F', '*'], +# 'Midden': ['#824E2B', 'o'], +# 'FluvialSediment': ['#4169E0','o'], +# 'TerrestrialSediment': ['#8A4513','o'], +# 'Shoreline': ['#add8e6','o'], +# 'Instrumental' : ['#8f21d8', '*'], +# 'Model' : ['#b4a7d6', "d"], +# 'Other': ['k', 'o'] +# } + +PLOT_DEFAULT = {'GlacierIce': ['deepskyblue', '*'], + 'GroundIce': ['slategray', '*'], + 'Borehole': ['#FFD600', 's'], + 'Coral': ['#FF8B00', 'v'], + 'Sclerosponge': ['r', 'v'], + 'Documents': ['#f8d568', 'p'], + 'Hybrid': ['#808000', 'H'], + 'LakeSediment': ['#1170aa', 'o'], + 'MarineSediment': ['#8A4513', 'o'], + 'FluvialSediment': ['#5fa2ce','o'], + 'TerrestrialSediment': ['#57606c','o'], 'Speleothem': ['#FF1492', 'd'], - 'Wood': ['#32CC32', '^'], - 'MolluskShell': ['#FFD600', 'h'], - 'Peat': ['#2F4F4F', '*'], - 'Midden': ['#824E2B', 'o'], - 'FluvialSediment': ['#4169E0','d'], - 'TerrestrialSediment': ['#8A4513','o'], - 'Shoreline': ['#add8e6','o'], - 'Instrumental' : ['#8f21d8', '*'], - 'Model' : ['#b4a7d6', "d"], + 'Wood': ['#8CD17D', '^'], + 'MolluskShell': ['#f8d568', 'h'], + 'Peat': ['#8A9A5B', 'X'], + 'Midden': ['#824E2B', 'X'], + 'Shoreline': ['#40826D','o'], + 'Instrumental' : ['#B07AA1', 'D'], + 'Model' : ['#E15759', "D"], 'Other': ['k', 'o'] } +# as per lipd convention, communicated by David Edge, 06.25.2024 +# var colorPal = {"Borehole":"#FFD600","MolluskShell":"#7b03fc","GlacierIce":"#86CDFA","GroundIce":"#ff6db6","Coral":"#FF8B00","FluvialSediment":"#4169E0","LakeSediment":"#8f8fa1","MarineSediment":"#8A4513","Speleothem":"#FF1492","Midden":"#824E2B","Peat":"#8A9A5B","Sclerosponge":"#D2042D","Shoreline":"#40826D","Wood":"#32CC32","TerrestrialSediment":"#d2b48c"} +# var shapePal ={"Borehole":"square","MolluskShell":"triangle","GlacierIce":"snowflake","GroundIce":"snowflake","Coral":"triangle-down","FluvialSediment":"circle","LakeSediment":"circle","MarineSediment":"circle","Speleothem":"square","Midden":"diamond","Peat":"triangle-down","Sclerosponge":"triangle","Shoreline":"diamond","Wood":"triangle","TerrestrialSediment":"circle"} """ diff --git a/pyleoclim/utils/mapping.py b/pyleoclim/utils/mapping.py index 4d75b270..7bb35331 100644 --- a/pyleoclim/utils/mapping.py +++ b/pyleoclim/utils/mapping.py @@ -563,7 +563,7 @@ def make_df(geo_ms, hue=None, marker=None, size=None, cols=None, d=None): def scatter_map(geos, hue='archiveType', size=None, marker='archiveType', edgecolor='k', - proj_default=True, projection='auto', crit_dist=5000, + proj_default=True, projection='auto', crit_dist=5000, title = None, background=True, borders=False, coastline=True, rivers=False, lakes=False, ocean=True, land=True, figsize=None, scatter_kwargs=None, gridspec_kwargs=None, extent='global', edgecolor_var=None, lgd_kwargs=None, legend=True, colorbar=True, cmap=None, color_scale_type=None, @@ -681,7 +681,9 @@ def scatter_map(geos, hue='archiveType', size=None, marker='archiveType', edgeco - 'missing_val_hue', 'missing_val_marker', 'missing_val_label' can all be used to change the way missing values are represented ('k', '?', are default hue and marker values will be associated with the label: 'missing'). - 'hue_mapping' and 'marker_mapping' can be used to submit dictionaries mapping hue values to colors and marker values to markers. Does not replace passing a string value for hue or marker. - 'scalar_mappable' can be used to pass a matplotlib scalar mappable. See pyleoclim.utils.plotting.make_scalar_mappable for documentation on using the Pyleoclim utility, or the `Matplotlib tutorial on customizing colorbars `_. - + + title : str + the title for the figure Returns ------- @@ -1323,9 +1325,12 @@ def replace_last(source_string, replace_what, replace_with): x = 'lon' y = 'lat' _, ax_d = plot_scatter(df=df, x=x, y=y, hue_var=hue, size_var=size, marker_var=marker, ax_d=ax_d, proj=None, - edgecolor=edgecolor, colorbar=colorbar, color_scale_type=color_scale_type, + edgecolor=edgecolor, colorbar=colorbar, color_scale_type=color_scale_type, title = title, cmap=cmap, scatter_kwargs=scatter_kwargs, legend=legend, lgd_kwargs=lgd_kwargs, **kwargs) # , **kwargs) + if title is not None: + ax_d['map'].set_title(title) + return fig, ax_d