Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add aggregate/downsample, upsample, extend and reduce methods to AreaDefinition and SwathDefinition #413

Open
wants to merge 12 commits into
base: main
Choose a base branch
from

Conversation

ghiggi
Copy link
Contributor

@ghiggi ghiggi commented Jan 18, 2022

This PR aims to provide the methods to aggregate/downsample, upsample, extend and reduce AreaDefinition and SwathDefinition objects.
For SwathDefinition objects, it returns a new objects with the same inputs lats/lons type (numpy/dask/xr.DataArray).

Yet to be done/defined:

  • I added the method downsample method and added a deprecation warning to AreaDefinition.aggregate?
  • Any reason to still use pyproj.transform instead of the more efficient pyproj.Transformer in SwathDefinition._do_transform?
  • For SwathDefinition.extend, do we define which pyproj.Geod? pyproj.Geod(ellps='sphere') or pyproj.Geod(ellps='WGS84') ?
  • I tried to make SwathDefinition.upsample dask-compatible. But I need some help/guidance to finish out the work. See between L828-L850 and L870-L880 of geometry.py.
  • SwathDefinition.upsample could also use instead geotiepoints.GeoInterpolator but I am not sure I understood its possible correct usage for upsampling a swath grid. Additionally it seems not dask compatible. See below PR comment for a code attempt ...
  • Extend and reduce methods for GEO AreaDef are not implemented. They need ad-hoc code. Ideas?

PS: Is my first PR to a package, so please be patient and don't hesitate to provide a lot of feedback/reproaches :)

@stickler-ci
Copy link
Contributor

There are 238 errors:

  • pyresample/geometry.py, line 718 - W291 trailing whitespace
  • pyresample/geometry.py, line 719 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 720 - W291 trailing whitespace
  • pyresample/geometry.py, line 721 - E501 line too long (124 > 120 characters)
  • pyresample/geometry.py, line 727 - W291 trailing whitespace
  • pyresample/geometry.py, line 732 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 733 - W291 trailing whitespace
  • pyresample/geometry.py, line 735 - W291 trailing whitespace
  • pyresample/geometry.py, line 740 - W291 trailing whitespace
  • pyresample/geometry.py, line 743 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 744 - W291 trailing whitespace
  • pyresample/geometry.py, line 747 - W291 trailing whitespace
  • pyresample/geometry.py, line 749 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 750 - W291 trailing whitespace
  • pyresample/geometry.py, line 751 - E225 missing whitespace around operator
    W291 trailing whitespace
  • pyresample/geometry.py, line 752 - W291 trailing whitespace
  • pyresample/geometry.py, line 753 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 754 - W291 trailing whitespace
  • pyresample/geometry.py, line 757 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 759 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 760 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 761 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 764 - W291 trailing whitespace
  • pyresample/geometry.py, line 766 - E261 at least two spaces before inline comment
  • pyresample/geometry.py, line 770 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 771 - W291 trailing whitespace
  • pyresample/geometry.py, line 773 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 774 - W291 trailing whitespace
  • pyresample/geometry.py, line 776 - E261 at least two spaces before inline comment
  • pyresample/geometry.py, line 777 - E261 at least two spaces before inline comment
  • pyresample/geometry.py, line 778 - E261 at least two spaces before inline comment
  • pyresample/geometry.py, line 781 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 783 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 784 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 785 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 788 - W291 trailing whitespace
  • pyresample/geometry.py, line 790 - W291 trailing whitespace
  • pyresample/geometry.py, line 793 - W291 trailing whitespace
  • pyresample/geometry.py, line 798 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 799 - W291 trailing whitespace
  • pyresample/geometry.py, line 801 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 804 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 806 - W291 trailing whitespace
  • pyresample/geometry.py, line 816 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 819 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 820 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 823 - E231 missing whitespace after ','
    W291 trailing whitespace
  • pyresample/geometry.py, line 825 - W291 trailing whitespace
  • pyresample/geometry.py, line 826 - E111 indentation is not a multiple of 4
    E117 over-indented
  • pyresample/geometry.py, line 827 - W291 trailing whitespace
  • pyresample/geometry.py, line 829 - W291 trailing whitespace
  • pyresample/geometry.py, line 831 - W291 trailing whitespace
  • pyresample/geometry.py, line 833 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 838 - E225 missing whitespace around operator
  • pyresample/geometry.py, line 840 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 842 - E261 at least two spaces before inline comment
    W291 trailing whitespace
  • pyresample/geometry.py, line 844 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 846 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 851 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 856 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 860 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 861 - W291 trailing whitespace
  • pyresample/geometry.py, line 862 - W291 trailing whitespace
  • pyresample/geometry.py, line 873 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 879 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 880 - W291 trailing whitespace
  • pyresample/geometry.py, line 883 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 885 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 886 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 887 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 890 - W291 trailing whitespace
  • pyresample/geometry.py, line 892 - E261 at least two spaces before inline comment
  • pyresample/geometry.py, line 896 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 897 - W291 trailing whitespace
  • pyresample/geometry.py, line 900 - W291 trailing whitespace
  • pyresample/geometry.py, line 902 - W291 trailing whitespace
  • pyresample/geometry.py, line 905 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 906 - E231 missing whitespace after ','
    W291 trailing whitespace
  • pyresample/geometry.py, line 907 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 908 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 909 - W291 trailing whitespace
  • pyresample/geometry.py, line 910 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 911 - W291 trailing whitespace
  • pyresample/geometry.py, line 913 - E261 at least two spaces before inline comment
  • pyresample/geometry.py, line 914 - E261 at least two spaces before inline comment
  • pyresample/geometry.py, line 915 - E261 at least two spaces before inline comment
  • pyresample/geometry.py, line 918 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 920 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 921 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 922 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 925 - W291 trailing whitespace
  • pyresample/geometry.py, line 927 - W291 trailing whitespace
  • pyresample/geometry.py, line 930 - W291 trailing whitespace
  • pyresample/geometry.py, line 935 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 936 - W291 trailing whitespace
  • pyresample/geometry.py, line 938 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 941 - W291 trailing whitespace
  • pyresample/geometry.py, line 944 - W291 trailing whitespace
  • pyresample/geometry.py, line 945 - W291 trailing whitespace
  • pyresample/geometry.py, line 948 - W291 trailing whitespace
  • pyresample/geometry.py, line 950 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 951 - W291 trailing whitespace
  • pyresample/geometry.py, line 952 - E225 missing whitespace around operator
    W291 trailing whitespace
  • pyresample/geometry.py, line 953 - W291 trailing whitespace
  • pyresample/geometry.py, line 954 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 956 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 957 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 958 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 961 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 964 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 965 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 968 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 970 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 972 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 973 - W291 trailing whitespace
  • pyresample/geometry.py, line 974 - W291 trailing whitespace
  • pyresample/geometry.py, line 975 - E111 indentation is not a multiple of 4
    E231 missing whitespace after ','
  • pyresample/geometry.py, line 976 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 977 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 978 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 979 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 980 - E111 indentation is not a multiple of 4
    E231 missing whitespace after ','
  • pyresample/geometry.py, line 981 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 982 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 983 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 985 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 986 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 987 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 992 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 993 - W291 trailing whitespace
  • pyresample/geometry.py, line 995 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 998 - W291 trailing whitespace
  • pyresample/geometry.py, line 1006 - W291 trailing whitespace
  • pyresample/geometry.py, line 1008 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1009 - E226 missing whitespace around arithmetic operator
    W291 trailing whitespace
  • pyresample/geometry.py, line 1010 - E501 line too long (140 > 120 characters)
  • pyresample/geometry.py, line 1011 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1012 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1013 - E501 line too long (141 > 120 characters)
  • pyresample/geometry.py, line 1014 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 1015 - W291 trailing whitespace
  • pyresample/geometry.py, line 1016 - E225 missing whitespace around operator
    W291 trailing whitespace
  • pyresample/geometry.py, line 1017 - W291 trailing whitespace
  • pyresample/geometry.py, line 1018 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 1020 - E226 missing whitespace around arithmetic operator
    E231 missing whitespace after ','
  • pyresample/geometry.py, line 1021 - E226 missing whitespace around arithmetic operator
    E231 missing whitespace after ','
  • pyresample/geometry.py, line 1700 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 1706 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 1707 - W291 trailing whitespace
  • pyresample/geometry.py, line 1709 - W291 trailing whitespace
  • pyresample/geometry.py, line 1711 - W291 trailing whitespace
  • pyresample/geometry.py, line 1714 - W291 trailing whitespace
  • pyresample/geometry.py, line 1716 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 1718 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1719 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1725 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1726 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1727 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1728 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1729 - W291 trailing whitespace
  • pyresample/geometry.py, line 1730 - W291 trailing whitespace
  • pyresample/geometry.py, line 1732 - W291 trailing whitespace
  • pyresample/geometry.py, line 1735 - W291 trailing whitespace
  • pyresample/geometry.py, line 1737 - W291 trailing whitespace
  • pyresample/geometry.py, line 1739 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 1741 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 1744 - W291 trailing whitespace
  • pyresample/geometry.py, line 1748 - W291 trailing whitespace
  • pyresample/geometry.py, line 1751 - W291 trailing whitespace
  • pyresample/geometry.py, line 1753 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1754 - E226 missing whitespace around arithmetic operator
    W291 trailing whitespace
  • pyresample/geometry.py, line 1756 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1757 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1759 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 1760 - W291 trailing whitespace
  • pyresample/geometry.py, line 1761 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1762 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1768 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1769 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1770 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1771 - E226 missing whitespace around arithmetic operator
  • pyresample/geometry.py, line 1772 - W291 trailing whitespace
  • pyresample/geometry.py, line 1773 - W291 trailing whitespace
  • pyresample/geometry.py, line 1775 - W291 trailing whitespace
  • pyresample/geometry.py, line 1778 - W291 trailing whitespace
  • pyresample/geometry.py, line 1780 - W291 trailing whitespace
  • pyresample/geometry.py, line 1782 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 1784 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 3128 - E302 expected 2 blank lines, found 1
    W291 trailing whitespace
  • pyresample/geometry.py, line 3130 - W291 trailing whitespace
  • pyresample/geometry.py, line 3141 - W291 trailing whitespace
  • pyresample/geometry.py, line 3150 - W291 trailing whitespace
  • pyresample/geometry.py, line 3157 - W291 trailing whitespace
  • pyresample/geometry.py, line 3158 - E231 missing whitespace after ','
  • pyresample/geometry.py, line 3159 - W291 trailing whitespace
  • pyresample/geometry.py, line 3165 - W291 trailing whitespace
  • pyresample/geometry.py, line 3166 - W291 trailing whitespace
  • pyresample/geometry.py, line 3172 - W291 trailing whitespace
  • pyresample/geometry.py, line 3174 - W291 trailing whitespace
  • pyresample/geometry.py, line 3178 - W291 trailing whitespace
  • pyresample/geometry.py, line 3179 - W291 trailing whitespace
  • pyresample/geometry.py, line 3181 - W291 trailing whitespace
  • pyresample/geometry.py, line 3185 - E203 whitespace before ','
    W291 trailing whitespace
  • pyresample/geometry.py, line 3187 - W291 trailing whitespace
  • pyresample/geometry.py, line 3190 - W291 trailing whitespace
  • pyresample/geometry.py, line 3191 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 3192 - W291 trailing whitespace
  • pyresample/geometry.py, line 3193 - W291 trailing whitespace
  • pyresample/geometry.py, line 3197 - W291 trailing whitespace
  • pyresample/geometry.py, line 3201 - W291 trailing whitespace
  • pyresample/geometry.py, line 3204 - W291 trailing whitespace
  • pyresample/geometry.py, line 3205 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 3206 - W291 trailing whitespace
  • pyresample/geometry.py, line 3207 - W291 trailing whitespace
  • pyresample/geometry.py, line 3218 - W291 trailing whitespace
  • pyresample/geometry.py, line 3219 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 3220 - W291 trailing whitespace
  • pyresample/geometry.py, line 3223 - E302 expected 2 blank lines, found 1
  • pyresample/geometry.py, line 3224 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 3225 - W291 trailing whitespace
  • pyresample/geometry.py, line 3226 - W291 trailing whitespace
  • pyresample/geometry.py, line 3228 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 3229 - E111 indentation is not a multiple of 4
    E261 at least two spaces before inline comment
  • pyresample/geometry.py, line 3230 - E114 indentation is not a multiple of 4 (comment)
  • pyresample/geometry.py, line 3231 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 3232 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 3233 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 3234 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 3235 - E111 indentation is not a multiple of 4
    W291 trailing whitespace
  • pyresample/geometry.py, line 3237 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 3238 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 3239 - W293 blank line contains whitespace
  • pyresample/geometry.py, line 3240 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 3241 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 3242 - E111 indentation is not a multiple of 4
    W291 trailing whitespace
  • pyresample/geometry.py, line 3243 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 3244 - E111 indentation is not a multiple of 4
  • pyresample/geometry.py, line 3245 - E111 indentation is not a multiple of 4

@ghiggi ghiggi changed the title Add initial methods for RW Aggregate/ downsample, upsample, extend and reduce methods for AreaDefinition and SwathDefinition Jan 18, 2022
@ghiggi ghiggi changed the title Aggregate/ downsample, upsample, extend and reduce methods for AreaDefinition and SwathDefinition Aggregate/downsample, upsample, extend and reduce methods for AreaDefinition and SwathDefinition Jan 18, 2022
raise ValueError('x and y arguments must be positive integers.')
if x >= np.floor(width / 2):
max_x = int(np.floor(width / 2)) - 1
raise ValueError("""You can at maximum reduce the along-track direction (x)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

W291 trailing whitespace


def extend(self, x=0, y=0):
"""Extend the swath definition along x (along-track) and y (across-track) dimensions.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

W293 blank line contains whitespace

def _convert_2D_array(arr, to, dims=None):
"""
Convert a 2D array to a specific format.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

W293 blank line contains whitespace


def _get_extended_lonlats(lon_start, lat_start, lon_end, lat_end, npts, transpose=True):
"""Utils employed by SwathDefinition.extend.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

W293 blank line contains whitespace

def _convert_2D_array(arr, to, dims=None):
"""
Convert a 2D array to a specific format.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

W293 blank line contains whitespace

else:
raise NotImplementedError

def _get_extended_lonlats(lon_start, lat_start, lon_end, lat_end, npts, transpose=True):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

E302 expected 2 blank lines, found 1


def _get_extended_lonlats(lon_start, lat_start, lon_end, lat_end, npts, transpose=True):
"""Utils employed by SwathDefinition.extend.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

W293 blank line contains whitespace

@djhoese
Copy link
Member

djhoese commented Jan 18, 2022

pre-commit.ci autofix

@codecov
Copy link

codecov bot commented Jan 18, 2022

Codecov Report

Merging #413 (2e5997c) into main (565966f) will increase coverage by 0.31%.
The diff coverage is 99.83%.

❗ Current head 2e5997c differs from pull request most recent head e80f44e. Consider uploading reports for the commit e80f44e to get more accurate results
Impacted file tree graph

@@            Coverage Diff             @@
##             main     #413      +/-   ##
==========================================
+ Coverage   93.86%   94.18%   +0.31%     
==========================================
  Files          65       65              
  Lines       11125    11706     +581     
==========================================
+ Hits        10443    11025     +582     
+ Misses        682      681       -1     
Flag Coverage Δ
unittests 94.18% <99.83%> (+0.31%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Impacted Files Coverage Δ
pyresample/geometry.py 89.44% <99.66%> (+2.43%) ⬆️
pyresample/test/test_geometry.py 99.43% <100.00%> (+0.12%) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 565966f...e80f44e. Read the comment docs.

@coveralls
Copy link

coveralls commented Jan 18, 2022

Coverage Status

Coverage increased (+0.3%) to 94.012% when pulling 2e5997c on ghiggi:feature-upsample-downsample into 565966f on pytroll:main.

@ghiggi
Copy link
Contributor Author

ghiggi commented Jan 18, 2022

This paragraph is to discuss the possibility of using geotiepoints.GeoInterpolator within SwathDefinition.upsample.
I might have misunderstood the usage of the GeoInterpolator, but I can't retrieve the correct upsampled centroids:

import numpy as np 
from geotiepoints.geointerpolator import GeoInterpolator

# Data Example 1 
x = 2
y = 2
src_lons = np.array([[5,9],
                     [5,9],
                     [5,9],
                     [5,9]])
src_lats = np.array([[6,6],
                     [4,4],
                     [0,0],
                     [-2,-2]])

# Data Example 2
lons = np.arange(-179.5, 179.5 + 0.5, 0.5)
lats = np.arange(-89.5,  0.5,  0.5)
src_lons, src_lats = np.meshgrid(lons, lats)
x = 2
y = 2

#--------------------------------
# Workflow 
height = src_lons.shape[0]
width = src_lons.shape[1]
   
tie_cols = np.arange(0, width*2, 2) 
fine_cols = np.arange(-1 + 1/x, width*2-1, 1/x*2) 

fine_rows = np.arange(-1 + 1/y, height*2-1, 1/y*2) 
tie_rows = np.arange(0, height*2, 2) 
   
# Print ties 
print(fine_rows)
print(tie_rows)

print(fine_cols)
print(tie_cols)

#--------------------------------
interpolator = GeoInterpolator((src_lons, src_lats),
                               (tie_rows, tie_cols),
                               (fine_rows, fine_cols),
                               2, 2)
interpolator.fill_borders("y", "x")  
lons_up, lats_up = interpolator.interpolate()
print("With fill borders:")
print(np.round(lats_up[:,0],2))  # stop at lats 0.5  instead of -1.5 (ex1) and -8.78 instead of 0.5 (ex2)
     
interpolator = GeoInterpolator((src_lons, src_lats),
                               (tie_rows, tie_cols),
                               (fine_rows, fine_cols),
                               1, 1) # 2,2, does not work with ex1
lons_up, lats_up = interpolator.interpolate()
print("")
print("Without fill borders:")
print(np.round(lats_up[:,0],2))  # With ex2, not using fill_borders enable to reach at least lat 0, but  do not estimate the borders
 

@ghiggi
Copy link
Contributor Author

ghiggi commented Jan 18, 2022

The code below documents an uncommon SwathDefinition edge case where swath_def.upsample(2,2).aggregate(2,2) does not return the same source swath_def (only close to the poles). It is an unrealistic swath, since everything along y direction points toward the pole ...

import numpy as np
from pyresample.geometry import SwathDefinition
np.set_printoptions(suppress=True)

lons = np.arange(-179.75, 179.75 + 0.5, 0.5)
lats = np.arange(-89.75,  0.5,  0.5)
lons, lats = np.meshgrid(lons, lats)
swath_def = SwathDefinition(lons=lons, lats=lats)

### Precision error (?) between upsampling and aggregating 
swath_def_rev = swath_def.upsample(y=2, x=2).aggregate(x=2, y=2) 
np.allclose(swath_def_rev.lons, swath_def.lons, atol=1e-01)
np.allclose(swath_def_rev.lats, swath_def.lats, atol=1e-03)
np.isclose(swath_def_rev.lons, swath_def.lons, atol=1e-08)
bool_arr = np.isclose(swath_def_rev.lons, swath_def.lons, atol=1e-08)
swath_def.lats[~bool_arr.any(axis=1),0]  # problematic latitudes 
np.sum(bool_arr)/np.size(bool_arr)*100

@ghiggi ghiggi marked this pull request as ready for review January 19, 2022 14:46
@ghiggi ghiggi changed the title Aggregate/downsample, upsample, extend and reduce methods for AreaDefinition and SwathDefinition Add aggregate/downsample, upsample, extend and reduce methods to AreaDefinition and SwathDefinition Jan 19, 2022
Copy link
Member

@mraspaud mraspaud left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you very much for this PR. I can see that a lot of work went into this, I am grateful for that.

In general, I think there is a lot of duplication of code or functionality that could be avoided, and be careful about not breaking backwards compatibility. Pyresample is used by many, so we have to be very careful when deprecating functions for example.

Some more detailed comments inline.

Thanks again for the hard work!

Comment on lines 1911 to 1918
# Check input validity
x = int(x)
y = int(y)
if x == 1 and y == 1:
return self
if x < 1 or y < 1:
raise ValueError('x and y arguments must be positive integers larger or equal to 1.')
# Downsample
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is duplicated code in the following function. However, why do we need to make sure these are integers? I think downsampling by a factor 1.5 is a perfectly valid usecase...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what do you mean by splitting a pixel of 1.5.
The idea of this function is to split each pixel in x x y other pixels so you would require integers.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking if I have an AreaDefinition that is 1500x1500 pixels and I want to downsample it to 1000x1000 pixels.

Comment on lines 1933 to 1935
width = int(self.width * x)
height = int(self.height * y)
return self.copy(height=height, width=width)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is a close duplicate of the one before, could we have instead

def upsample(self, x=1, y=1):
    return self.downsample(1/x, 1/y)

?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@@ -1423,10 +1901,119 @@ def copy(self, **override_kwargs):

def aggregate(self, **dims):
"""Return an aggregated version of the area."""
warnings.warn("'aggregate' is deprecated, use 'downsample' or 'upsample' instead.", PendingDeprecationWarning)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this deprecated? Is there a problem with this method?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. It was just to have method name consistency with SwathDefinition.
If the input arguments are -1<x<1 the method currently fails later on when calling AreaDef methods ...
downsample is the opposite of upsample .
The opposite of aggregate would be disaggregate ... thinking to image processing is more straightforward up/down sampling in my opinion. I remove the DeprecationWarning?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If aggregate is breaking stuff it should definitely be fixed. I'm not against having upsample/downsample, but if aggregate and downsample are synonyms, one should call the other.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

pyresample/geometry.py Show resolved Hide resolved
pyresample/geometry.py Show resolved Hide resolved
@@ -1872,26 +1872,357 @@ def test_compute_optimal_bb(self):
assert_np_dict_allclose(res.proj_dict, proj_dict)
self.assertEqual(res.shape, (6, 3))

def test_aggregation(self):
def test_downsampling(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These test functions are way too long and have a lot of duplicated code. Please refactor these, and try to stick with the principle that one test function should assert one thing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate a bit more? I am new to tests.
Should I remove all testing of different array types?
The other tests were necessary to not reduce coverage ...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you don't need to remove anything, everything you test here is valid of course. What I mean is that it's easier to read and debug if you keep small tests. So this test function here should be split in many smaller test functions, for example test_downsampling_keeps_ndarrays that would just do assert isinstance(res_np.lats, np.ndarray), then another function called eg test_downsampling_keeps_DataArray with assert isinstance(res_xr.lats, xr.DataArray), etc.
All the code that will be duplicated between the smaller test should be put in a common setup method, so you might need to create more test cases.
I'm advising that each test should just test one behaviour of what function you are testing.

Copy link
Contributor Author

@ghiggi ghiggi Feb 2, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to follow your suggestions @mraspaud. Have a look at the TestSwathDefinitionDownsampling class in test_geometry and tell me if that is what you expect.
I wait for your feedbacks ... and then I will adapt also the rest of the tests.
Is not clear to me if I need to create a test class for each method in the end ...

Comment on lines 1354 to 1355
geod = pyproj.Geod(ellps='sphere') # TODO: sphere or WGS84?
# geod = pyproj.Geod(ellps='WGS84') # sphere
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not have this as a kwarg to let the user choose?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines 1939 to 1940
if self.is_geostationary:
raise NotImplementedError("'extend' method is not implemented for GEO AreaDefinition.")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why can't the area extents of a geos area def be extended?

Copy link
Contributor Author

@ghiggi ghiggi Feb 2, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can extend the shape of the area, but this might not affect the actual disc within.
In the case of full disc geo area, it just adds inf row/columns around the original full disc ...

In the case we want to shrink/reduce the full disc geo area, doing
area_to_cover.shrink(x=1000,y=1000) or area_to_cover[1000:-1000,1000:-1000] results in the attached image.
image
image

I am not sure this is the expected behaviour ...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also replaced x and y arguments with left, right, bottom, top arguments to be consistent with SwathDefinition methods

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, left, right, bottom, top make not lot of sense with geos disc area ...

pyresample/geometry.py Outdated Show resolved Hide resolved
pyresample/test/test_geometry.py Outdated Show resolved Hide resolved
@mraspaud
Copy link
Member

Also, this review took me more than 2 hours, in the future please consider making more incremental changes in each PR 😅

pyresample/geometry.py Outdated Show resolved Hide resolved
@ghiggi
Copy link
Contributor Author

ghiggi commented Jan 25, 2022

I plan to do the changes tomorrow afternoon @djhoese @mraspaud . Should I resolve the conservation comments after having resolved your concerns?

@mraspaud
Copy link
Member

Better leave them open if it's not trivial, so we can have another look after it's fixed and approve.

Copy link
Member

@djhoese djhoese left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ghiggi You marked a ton of stuff as done, but I don't see any new commits. Did you maybe forget to push?

@ghiggi ghiggi requested a review from mraspaud February 2, 2022 17:31
Copy link
Member

@djhoese djhoese left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed what I could of the main code. I skipped over the tests as I see that they are pretty long and I had larger scale questions/comments on the main code. I think you have some really good ideas in this PR. I'm not sure I understand everything you're going for, but that's where I asked questions. I'm a little worried about performance in the different array cases and error handling/logging when a user doesn't have xarray or dask installed but the method requires it.

Part of me thinks the upsample/downsample stuff should be put in a separate module as a generic geocentric (or geographic?) upsample/downsample pair of functions. The methods in SwathDefinition could then be simple calls to these functions and keep the amount of code in geometry.py down a bit (it is already way too long before this PR).


Docs: https://pyproj4.github.io/pyproj/stable/advanced_examples.html#multithreading
"""
if float(pyproj.__version__[0:3]) >= 3.1:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason you have to convert this to float? Won't string comparison work fine? Otherwise, it may be better to use the packaging's version parsing and Version object: https://packaging.pypa.io/en/latest/version.html. If I remember correctly this is a dependency of setuptools which we also depend on so it should be fine dependency-wise.

Lastly, @mraspaud I'd be OK with just doing a hard require on pyproj >=3.1 for pyresample. It is 6 months old and pyproj moves pretty fast. I don't think it is too much to ask for the user to use a new-ish version. Granted we are only limiting to pyproj 2.2.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if pyproj gets to version 3.10? in floats, that's 3.1...
@djhoese I could live with that too.

"""
if float(pyproj.__version__[0:3]) >= 3.1:
from pyproj import Transformer
transformer = Transformer.from_crs(src.crs, dst.crs)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to use always_xy=True flag for either from_crs or transform. @mraspaud since you most recently ran into this, what do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, for our use cases, always_xy=True is a must

Comment on lines 748 to +749
import dask.array as da
import pyproj
import xarray as xr
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DataArray and da are already imported at the top of the module. Can we use those instead? And/or maybe have an if statement here if it requires xarray and/or dask then it can raise an ImportError saying that downsample requires those.

Comment on lines +113 to +115
to : TYPE
The desired array output format.
Accepted formats are: ['Numpy','Dask', 'DataArray_Numpy','DataArray_Dask']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really don't like this custom sub-language for this parameter. I would rather have separate explicit functions that the user knows it has to call. The function can convert between what it was given to what is needed, but we can avoid all this complex string parsing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To add to this and clarify a little, you could use actually class objects for this (np.ndarray, da.Array, xr.DataArray, etc). Or even better (at least that's my current feeling) do the split functions like I mentioned and now that I realize you return the input type, you could instead return the function needed to reverse the conversion.

Or...you could do something like a decorator on the methods that replaces your use of these functions so that the internal implementation of the methods only deals with the object types they want. All the conversion complexity is kept outside of the internals/implementation. I do realize that there are some edge cases where a conversion would be done when nothing was really modified in the data, but if done correctly that shouldn't be too big of a deal.

These are just ideas. I can definitely be convinced otherwise, but the new string names for these things feels like the wrong approach.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK I will take some time to think of your suggestions and try implementing it.
Just to be sure @djhoese:

  • You mean to pass in the function argument the class object (np.ndarray, da.Array, xr.DataArray) we want instead of a string-acronym right?
  • Should I add a chunked/lazy=True/False argument to decide whether to return an xr.DataArray with dask or numpy array? Or default to a xr.DataArray with numpy?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing the class object was one option. My separate functions is still what I'd prefer. For example a to_data_array_dask, to_data_array_numpy, to_numpy, or to_dask. Then the return values of these would be (converted_obj, to_numpy) depending on what it detected was given.

Although I'm not sure how xarray coords work in a situation like this. Like if you gave it a DataArray, it converted to numpy, then converted back to a DataArray, you wouldn't know the dimension names or the coordinates or any attributes. Xarray typically loses attributes when you do some operation so that's not a huge deal to mimic that, but coords and dims would be something the main function wouldn't be aware of. I guess that's another reason a decorator would be nice for this.

"""Downsample the SwathDefinition along x (columns) and y (lines) dimensions.

Builds upon xarray.DataArray.coarsen averaging function.
To downsample of a factor of 2, call swath_def.downsample(x=2, y=2)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
To downsample of a factor of 2, call swath_def.downsample(x=2, y=2)
To downsample of a factor of 2, call ``swath_def.downsample(x=2, y=2)``.


Builds upon xarray.DataArray.coarsen averaging function.
To downsample of a factor of 2, call swath_def.downsample(x=2, y=2)
swath_def.downsample(x=1, y=1) simply returns the current swath_def.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
swath_def.downsample(x=1, y=1) simply returns the current swath_def.
``swath_def.downsample(x=1, y=1)`` simply returns the current swath_def.

warnings.warn("'aggregate' is deprecated, use 'downsample' instead.", PendingDeprecationWarning)
return self.downsample(x=dims.get('x', 1), y=dims.get('y', 1))

def downsample(self, x=1, y=1, **kwargs):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a google-style docstring and explain what kwargs is?

res = xr.DataArray(res, dims=['y', 'x', 'xyz'], coords=src_lons.coords)

# Aggregating
res = res.coarsen(x=x, y=y, **kwargs).mean()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably wait for a separate PR, but it'd be nice if mean was a keyword argument that the user could control.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can do also this.
There is a small set of possible functions that can be called from an xarray.core.rolling.DataArrayCoarsen object. I can add the default argument fun="mean" and then do:

res = res.coarsen(x=x, y=y, **kwargs)
func = getattr(res, fun')
res = func(res)

Should I check for valid fun values in set("min", "max", "sum", "std", "var", "count", "mean", "median") ? Or defer this to the xarray function?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say add an explicit keyword argument in the method signature for coarsen_func or something like that and document in the docstring to look at the xarray docs (link to the exact page if possible, intersphinx may allow you to do something like:

See the :doc:`Xarray coarsen documentation <xarray:coarsen-docs-page>` for more information.

And I think you could leave the error handling up to the getattr. You could shorten this to:

coarsen_res = res.coarsen(...)
res = getattr(coarsen_res, coarsen_func)()

This assumes the coarsen function doesn't take additional kwargs.

Comment on lines +918 to +922
def extend(self, left=0, right=0, bottom=0, top=0):
"""Extend the SwathDefinition of n pixels on specific boundary sides.

By default, it does not extend on any side.
"""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may have had this discussion with Martin in previous comment sections, but what is this method used for? What is the real world use case?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def extend(self, left=0, right=0, bottom=0, top=0):
"""Extend the SwathDefinition of n pixels on specific boundary sides.
By default, it does not extend on any side.
"""
def extend(self, left=0, right=0, bottom=0, top=0):
"""Extend the SwathDefinition by n pixels on specific boundary sides.
By default, it does not extend on any side.
"""

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am currently implementing a pipeline for collocating and remapping GEO imagery on LEO sensor swath.
It's very useful to actually have GEO imagery outside the original LEO swath for downstream image processing/ML applications.

As an example, having a LEO sensor with 5 km footprint resolution, I first extend the LEO SwathDefinition of N pixels on both sides along-track, then I upsample the SwathDefinition by a factor of 5 to match the 1km resolution of GEO imagery. --> swathdef.extend(top=N, bottom=N).upsample(x=5,y=5)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the end result is a SwathDefinition that is the same relative resolution as the GEO data and because of the along-track extend the SwathDefinition is now "longer". Is that useful because then resampled GEO data has an overlap with multiple LEO swath definitions (multiple individual granules of the LEO data)? Or do you just want as much GEO data in the area of the LEO data? If so, then why not extend it cross-track too?

My non-ML-experienced instinct would be to resample the GEO data directly to the LEO swath, but I suppose that loses too much of the GEO data in the preparation for the ML stages rather than keeping that data for the ML stages to work with. Do you also resample the LEO data to the 1km SwathDefinition? Or just upsample it?

Copy link
Contributor Author

@ghiggi ghiggi Feb 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I stupidly mentioned that I am extending along-track while I am extending the SwathDefinition cross-track ;)

I essentially want to have neighbor information around the swath and upscale it:

  1. in order to not lose potential fine-scale information available from GEO data (i.e. overshooting tops, inhomogeneity/non-uniform beam filling degree,...).
  2. and so that if I apply a specific series of convolutions/pooling operations without padding (i.e. with CNN), which cause the extended and upscaled SwathDefintion grids to get shrunk, I can come up with an output that is aligned with the original LEO SwathDefinition.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the ML code handle the geolocation of the data at all? Or are you telling it (in some way) that the top-left 2x2 pixels of the resampled GEO pixels represent pixel [0, 0] of the LEO data? Or I guess in this extended case the extrapolated pixels of the GEO data don't have any corresponding LEO pixel.

If the ML has a concept of the location of the data, then why not simply slice the GEO data to the bounds of the LEO data instead of resampling?

Note: I'm not trying to argue against this extend method I just really want to understand how it is used so I can argue for/against changes in the future.

pyresample/geometry.py Show resolved Hide resolved
@djhoese djhoese added backwards-incompatibility Causes backwards incompatibility or introduces a deprecation enhancement labels Feb 8, 2022
# Retrieve new centroids
# TODO: make it dask compatible using _upsample_centroid_dask [HELP WANTED]
# res1 = da.apply_along_axis(_upsample_centroid_dask,
# 2,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@djhoese have a look at this portion of code when you will have half an hour.
I am not sure what is the best way to use dask here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not fully understand the logic here, but I understand the overall goal. To summarize we have an overall array of (M rows, N columns) and we want to produce a final array with a shape of (M * y, N * x), right? To do this we need convert from lon/lat to x/y/z geocentric, do some math, then convert back x/y/z to lon/lat. In the "do some math" portion, do we need to the values of all the pixels next to the current pixel? Or can we operate on groups of y by x pixels?

Given what I've learned about number of tasks in a dask graph, I think it would be best to do this entire thing in a giant map_blocks call. By "entire thing" I mean lon/lat -> x/y/z, the math, x/y/z -> lon/lat. If you require to work on groups of y by x pixels then you could make sure that the input chunks are divisible by those numbers (2x2 -> 1024x1024 would be ok). We don't want to make really small chunks. If you need overlap between the computations (in the case where each pixel needs to know about all the values around it) then map_overlap may be something to look into: https://docs.dask.org/en/stable/array-overlap.html

def upsample(self, x=1, y=1):
"""Upsample the SwathDefinition along x (columns) and y (lines) dimensions.

To upsample of a factor of 2 (each pixel splitted in 2x2 pixels),
Copy link
Member

@djhoese djhoese Feb 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
To upsample of a factor of 2 (each pixel splitted in 2x2 pixels),
To upsample by a factor of 2 (each pixel split into 2x2 pixels),

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
backwards-incompatibility Causes backwards incompatibility or introduces a deprecation enhancement
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants