Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/api/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Utilities
.. autosummary::
:toctree: generated/

minmax
maxabs
variance_to_weights
grid_to_table
Expand Down
2 changes: 1 addition & 1 deletion verde/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from .scipygridder import Cubic, Linear, ScipyGridder
from .spline import Spline, SplineCV
from .trend import Trend
from .utils import grid_to_table, make_xarray_grid, maxabs, variance_to_weights
from .utils import grid_to_table, make_xarray_grid, maxabs, minmax, variance_to_weights
from .vector import Vector, VectorSpline2D


Expand Down
70 changes: 70 additions & 0 deletions verde/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
make_xarray_grid,
meshgrid_from_1d,
meshgrid_to_1d,
minmax,
parse_engine,
partition_by_sum,
)
Expand Down Expand Up @@ -334,3 +335,72 @@ def test_check_ndim_easting_northing():
northing = np.linspace(-5, 5, 16).reshape(4, 4)
with pytest.raises(ValueError):
get_ndim_horizontal_coords(easting, northing)


def test_minmax_nans():
"""
Test minmax handles nans correctly
"""
assert tuple(map(float, minmax((-1, 100, 1, 2, np.nan)))) == (-1, 100)
assert tuple(map(float, minmax((np.nan, -3.2, -1, -2, 3.1)))) == (-3.2, 3.1)
assert np.all(np.isnan(minmax((np.nan, -3, -1, 3), nan=False)))


def test_minmax_percentile():
"""
Test minmax with percentile option
"""
# test generic functionality
data = np.arange(0, 101)

result = tuple(map(float, minmax(data, percentile=(0, 100))))
assert result == (0, 100)
result = tuple(map(float, minmax(data, percentile=(10, 90))))
assert pytest.approx(result, 0.1) == (10, 90)

# test with nans
data_with_nans = np.append(data, np.nan)
result = tuple(map(float, minmax(data_with_nans, percentile=(0, 100))))
assert result == (0, 100)
result = tuple(map(float, minmax(data_with_nans, percentile=(10, 90))))
assert pytest.approx(result, 0.1) == (10, 90)
result = tuple(map(float, minmax(data_with_nans, percentile=(10, 90), nan=True)))
assert pytest.approx(result, 0.1) == (10, 90)
result = minmax(data_with_nans, percentile=(10, 90), nan=False)
assert np.all(np.isnan(result))

# test with varying array sizes
result = tuple(
map(float, minmax([0, 1, 2, 3, 4], [[-2, 2], [0, 5]], percentile=(0, 100)))
)
assert result == (-2, 5)
result = tuple(
map(float, minmax([0, 1, 2, 3, 4], [[-2, 2], [0, 5]], percentile=(1, 99)))
)
assert pytest.approx(result, 0.1) == (-1.84, 4.92)

# test invalid percentile types
with pytest.raises(TypeError):
minmax(data, percentile="90")
with pytest.raises(TypeError):
minmax(data, percentile=100)
with pytest.raises(TypeError):
minmax(data, percentile=None)
minmax(data, percentile=(1, 99)) # should not raise
minmax(data, percentile=[1, 99]) # should not raise

# test invalid percentile values
with pytest.raises(ValueError):
minmax(data, percentile=[-10, 90])
with pytest.raises(ValueError):
minmax(data, percentile=[0, 110])
with pytest.raises(ValueError):
minmax(data, percentile=[-10, 110])

# test invalid percentile length
with pytest.raises(TypeError):
minmax(data, percentile=[10, 50, 90])
with pytest.raises(TypeError):
minmax(data, percentile=[])
with pytest.raises(TypeError):
minmax(data, percentile=(50))
98 changes: 98 additions & 0 deletions verde/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,104 @@ def maxabs(*args, nan=True):
return npmax(absolute)


def minmax(*args, nan=True, percentile=(0, 100)):
"""
Calculate the minimum and maximum values of the given array(s).

Use this to set the limits of your colorbars for non-diverging data.

Parameters
----------
args
One or more arrays. If more than one are given, a minimum and maximum
will be calculated across all arrays.
nan : bool
If True, will use the ``nan`` version of numpy functions to ignore
NaNs.
percentile : tuple[float, float]
Instead of return the minimum and maximum values, return the two
percentilesprovided. Each must be between 0 and 100. (0, 100) (default)
will give the minimum and maximum values, while (2, 98) will give the
2% and 98% percentiles of the data, respectively, which is more robust
to outliers.

Returns
-------
minmax : tuple[float, float]
The minimum and maximum (or percentile) values across all arrays.

Examples
--------

>>> result = minmax((1, -10, 25, 2, 3))
>>> tuple(map(float, result))
(-10.0, 25.0)
>>> result = minmax(
... (1, -10.5, 25, 2), (0.1, 100, -500), (-200, -300, -0.1, -499)
... )
>>> tuple(map(float, result))
(-500.0, 100.0)

If the array contains NaNs, we'll use the ``nan`` version of of the numpy
functions by default. You can turn this off through the *nan* argument.

>>> import numpy as np
>>> result = minmax((1, -10, 25, 2, 3, np.nan))
>>> tuple(map(float, result))
(-10.0, 25.0)
>>> result = minmax((1, -10, 25, 2, 3, np.nan), nan=False)
>>> tuple(map(float, result))
(nan, nan)

If a more robust statistic is desired, you can use ``percentile`` to get
the values at given percentiles instead of the minimum and maximum.

>>> import numpy as np
>>> result = minmax((1, -10, 25, 2, 3), percentile=[2, 98])
>>> tuple(map(float, result))
(-9.12, 23.24)
>>> result = minmax((1, -10, 25, 2, 3), percentile=[0, 100])
>>> tuple(map(float, result))
(-10.0, 25.0)

"""
arrays = [np.atleast_1d(i) for i in args]

if percentile == (0, 100) or percentile == [0, 100]:
if nan:
npmin, npmax = np.nanmin, np.nanmax
else:
npmin, npmax = np.min, np.max
# Get min and max of each array
mins_maxs = [([npmin(i), npmax(i)]) for i in arrays]
# Get min of mins and max of maxs
return (npmin([i[0] for i in mins_maxs]), npmax([i[1] for i in mins_maxs]))
else:
if not isinstance(percentile, tuple | list):
raise TypeError(
f"Invalid 'percentile' of type '{type(percentile).__name__}'. Percentile must be a tuple or list."
)
if not all(isinstance(p, float | int) for p in percentile):
raise TypeError(
"Invalid type for elements of 'percentile'; should contain only floats or integers."
)
if len(percentile) != 2:
raise TypeError(
f"Invalid length ({len(percentile)}) for 'percentile'. It must have exactly two elements."
)
if any(p < 0 or p > 100 for p in percentile):
raise ValueError(
f"Invalid values in 'percentile' '{percentile}'. Values must be between 0 and 100."
)
if nan:
nppercentile = np.nanpercentile
else:
nppercentile = np.percentile
# concatenate values of all arrays
combined_array = np.concatenate([a.ravel() for a in arrays])
return tuple(nppercentile(combined_array, percentile))


def make_xarray_grid(
coordinates,
data,
Expand Down
Loading