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

Added option for dimension range padding #2293

Merged
merged 48 commits into from
Sep 13, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
98aa9d3
Added option for dimension range padding
philippjfr Feb 1, 2018
5e69327
Added dimension_range keyword to range method
philippjfr Jul 4, 2018
cdccca0
Fixed minor bug in range computation
philippjfr Jul 4, 2018
3d23908
Handle range padding on Overlays
philippjfr Jul 4, 2018
5579510
Fixed flakes
philippjfr Jul 4, 2018
6155d92
Various fixes
philippjfr Jul 4, 2018
bb1b811
Correctly combine data range and hard range
philippjfr Jul 4, 2018
5e1f8d5
Fixed unit tests
philippjfr Jul 4, 2018
6df0dd5
Include extent in data calculation for now
philippjfr Jul 4, 2018
cc93f81
Small fixes for Histogram and BarPlot
philippjfr Jul 4, 2018
7bb1e21
Fix for SpikesPlot
philippjfr Jul 4, 2018
c71147b
Allow tuple padding value
philippjfr Jul 6, 2018
a993089
Pad axes in screen space and handle log axes
philippjfr Jul 14, 2018
6760bee
Better handling for default_span
philippjfr Jul 14, 2018
65ed72b
Added unit test for Curve and PointPlot padding
philippjfr Jul 14, 2018
3ae07da
Added unit tests for SpreadPlot padding
philippjfr Jul 14, 2018
3dd5123
Added unit tests for padding on SpikePlot
philippjfr Jul 14, 2018
6b77306
Added unit tests for HistogramPlot padding
philippjfr Jul 14, 2018
041854e
Fixed and added tests for BarPlot padding
philippjfr Jul 14, 2018
ff09ee0
Added padding unit tests
philippjfr Jul 14, 2018
2df0529
Various bug fixes and improvements
philippjfr Jul 14, 2018
e8d28cd
Fix for matplotlib AreaPlot and unit tests
philippjfr Jul 14, 2018
fed3a1f
Fixed flakes
philippjfr Jul 14, 2018
0cbe021
Fixed HistogramPlot unit test
philippjfr Jul 14, 2018
020fbe1
Rearchitected get_extents method
philippjfr Jul 15, 2018
f1e247d
Fixed bug in mpl histogram datetime handling
philippjfr Jul 15, 2018
bf10ed9
Fix for RasterGridPlot
philippjfr Jul 15, 2018
a180d46
Fixed handling of Element.extents
philippjfr Jul 15, 2018
fa15bd6
Added xlim/ylim/zlim plot options
philippjfr Jul 15, 2018
76e24e1
Add docstring for padding
philippjfr Jul 15, 2018
e0b9567
Improved docstrings
jbednar Jul 15, 2018
091d87e
Add support for two-sided padding
philippjfr Aug 11, 2018
e627820
Made padding value one sided in all cases
philippjfr Aug 11, 2018
ae0e03a
Fixed small bug
philippjfr Aug 12, 2018
c7fe120
Updated tests
philippjfr Aug 12, 2018
607568e
Added unit tests for per axis padding
philippjfr Aug 12, 2018
543a25d
Refactored extents code
philippjfr Aug 12, 2018
bd3e4d5
Added handling for stats plots
philippjfr Aug 12, 2018
262bea1
Fixed flakes
philippjfr Aug 21, 2018
b4fb02b
Fixed unreferenced variable
philippjfr Aug 21, 2018
b4a15a9
Added tests for range related plotting utilities
philippjfr Sep 12, 2018
458c0a5
Improved docstrings for default_span and get_extents method
philippjfr Sep 12, 2018
c50200a
Improved range method docstring
philippjfr Sep 12, 2018
bbb547b
Refactored OverlayPlot.get_extents
philippjfr Sep 13, 2018
acd80fb
Added tests for padding 3d plots
philippjfr Sep 13, 2018
3c92124
Attempt to fix flexx dependency on travis
philippjfr Sep 13, 2018
cbbe3fa
Flake fixes
philippjfr Sep 13, 2018
bc2b8ec
Minor fix for docstring
philippjfr Sep 13, 2018
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
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ install:
- conda info -a
- conda create -q -n test-environment python=$TRAVIS_PYTHON_VERSION flake8 scipy=1.0.0 numpy freetype nose pandas=0.22.0 jupyter ipython=5.4.1 param matplotlib=2.1.2 xarray networkx
- source activate test-environment
- conda install -c conda-forge filelock iris plotly=2.7 flexx ffmpeg netcdf4=1.3.1 --quiet
- conda install -c conda-forge filelock iris plotly=2.7 flexx=0.4.1 ffmpeg netcdf4=1.3.1 --quiet
- conda install -c bokeh datashader dask bokeh=0.12.15 selenium
- if [[ "$TRAVIS_PYTHON_VERSION" == "3.4" ]]; then
conda install python=3.4.3;
Expand Down
26 changes: 18 additions & 8 deletions holoviews/core/data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import param

from ..dimension import redim
from ..util import dimension_range, unique_iterator
from ..util import unique_iterator
from .interface import Interface, iloc, ndloc
from .array import ArrayInterface
from .dictionary import DictInterface
Expand Down Expand Up @@ -274,22 +274,32 @@ def sort(self, by=[], reverse=False):
return self.clone(sorted_columns)


def range(self, dim, data_range=True):
def range(self, dim, data_range=True, dimension_range=True):
Copy link
Contributor

Choose a reason for hiding this comment

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

Would be good to update the docstring to explain this new flag.

Copy link
Member Author

Choose a reason for hiding this comment

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

Agreed, should be done here and in the Dimensioned baseclass at least.

"""
Computes the range of values along a supplied dimension, taking
into account the range and soft_range defined on the Dimension
object.
Returns the range of values along the specified dimension.

dimension: str/int/Dimension
The dimension to compute the range on.
data_range: bool
Whether the range should include the data range or only
the dimension ranges
dimension_range:
Whether to compute the range including the Dimension range
and soft_range
"""
dim = self.get_dimension(dim)
if dim is None:

if dim is None or (not data_range and not dimension_range):
return (None, None)
elif all(util.isfinite(v) for v in dim.range):
elif all(util.isfinite(v) for v in dim.range) and dimension_range:
return dim.range
elif dim in self.dimensions() and data_range and len(self):
lower, upper = self.interface.range(self, dim)
else:
lower, upper = (np.NaN, np.NaN)
return dimension_range(lower, upper, dim)
if not dimension_range:
return lower, upper
return util.dimension_range(lower, upper, dim.range, dim.soft_range)


def add_dimension(self, dimension, dim_pos, dim_val, vdim=False, **kwargs):
Expand Down
24 changes: 16 additions & 8 deletions holoviews/core/dimension.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
import numpy as np
import param

from ..core import util
from ..core.util import (basestring, sanitize_identifier, isfinite,
group_sanitizer, label_sanitizer, max_range,
find_range, dimension_sanitizer, OrderedDict,
bytes_to_unicode, unicode, dt64_to_dt, unique_array,
builtins, config, dimension_range, disable_constant)
builtins, config, disable_constant)
from .options import Store, StoreOptions
from .pprint import PrettyPrinter

Expand Down Expand Up @@ -1104,18 +1105,23 @@ def dimension_values(self, dimension, expanded=True, flat=True):
(dimension, self.__class__.__name__))


def range(self, dimension, data_range=True):
def range(self, dimension, data_range=True, dimension_range=True):
"""
Returns the range of values along the specified dimension.

If data_range is True, the data may be used to try and infer
the appropriate range. Otherwise, (None,None) is returned to
indicate that no range is defined.
dimension: str or int or Dimension
The dimension to compute the range on.
data_range: bool (optional)
Whether the range should include the data range or only
the dimension ranges
dimension_range: bool (optional)
Whether to compute the range including the Dimension range
and soft_range.
"""
dimension = self.get_dimension(dimension)
if dimension is None:
if dimension is None or (not data_range and not dimension_range):
return (None, None)
elif all(isfinite(v) for v in dimension.range):
elif all(isfinite(v) for v in dimension.range) and dimension_range:
return dimension.range
elif data_range:
if dimension in self.kdims+self.vdims:
Expand All @@ -1129,7 +1135,9 @@ def range(self, dimension, data_range=True):
lower, upper = max_range(ranges)
else:
lower, upper = (np.NaN, np.NaN)
return dimension_range(lower, upper, dimension)
if not dimension_range:
return lower, upper
return util.dimension_range(lower, upper, dimension.range, dimension.soft_range)


def __repr__(self):
Expand Down
4 changes: 2 additions & 2 deletions holoviews/core/spaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -845,11 +845,11 @@ def _validate_key(self, key):
for ind, val in enumerate(key):
kdim = self.kdims[ind]
low, high = util.max_range([kdim.range, kdim.soft_range])
if low is not np.NaN:
if util.is_number(low) and util.isfinite(low):
if val < low:
raise KeyError("Key value %s below lower bound %s"
% (val, low))
if high is not np.NaN:
if util.is_number(high) and util.isfinite(high):
if val > high:
raise KeyError("Key value %s above upper bound %s"
% (val, high))
Expand Down
72 changes: 58 additions & 14 deletions holoviews/core/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -810,36 +810,80 @@ def find_range(values, soft_range=[]):
return (None, None)


def max_range(ranges):
def is_finite(value):
"""
Safe check whether a value is finite, only None, NaN and inf
values are considered non-finite and allows checking all types not
restricted to numeric types.
"""
if value is None:
return False
try:
return np.isfinite(value)
except:
return True


def max_range(ranges, combined=True):
"""
Computes the maximal lower and upper bounds from a list bounds.
"""
try:
with warnings.catch_warnings():
warnings.filterwarnings('ignore', r'All-NaN (slice|axis) encountered')
values = [r for r in ranges for v in r if v is not None]
values = [tuple(np.NaN if v is None else v for v in r) for r in ranges]
if pd and all(isinstance(v, pd.Timestamp) for r in values for v in r):
values = [(v1.to_datetime64(), v2.to_datetime64()) for v1, v2 in values]
arr = np.array(values)
if arr.dtype.kind in 'OSU':
arr = list(python2sort([v for v in arr.flat if not is_nan(v) and v is not None]))
if not len(arr):
return np.NaN, np.NaN
elif arr.dtype.kind in 'OSU':
arr = list(python2sort([v for r in values for v in r if not is_nan(v) and v is not None]))
return arr[0], arr[-1]
if arr.dtype.kind in 'M':
return arr[:, 0].min(), arr[:, 1].max()
return (np.nanmin(arr[:, 0]), np.nanmax(arr[:, 1]))
elif arr.dtype.kind in 'M':
return (arr.min(), arr.max()) if combined else (arr[:, 0].min(), arr[:, 1].min())
if combined:
return (np.nanmin(arr), np.nanmax(arr))
else:
return (np.nanmin(arr[:, 0]), np.nanmax(arr[:, 1]))
except:
return (np.NaN, np.NaN)


def dimension_range(lower, upper, dimension):
def range_pad(lower, upper, padding=None, log=False):
"""
Pads the range by a fraction of the interval
Copy link
Contributor

Choose a reason for hiding this comment

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

I didn't spot any unit tests explicitly for these utilities. I know they should be tested indirectly by the more comprehensive unit tests in this PR but a couple of utility unit tests might be useful as examples of the expected input/output...

Copy link
Member Author

Choose a reason for hiding this comment

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

Agreed, should be straightforward.

"""
if padding is not None and not isinstance(padding, tuple):
padding = (padding, padding)
if is_number(lower) and is_number(upper) and padding is not None:
if not isinstance(lower, datetime_types) and log and lower > 0 and upper > 0:
log_min = np.log(lower) / np.log(10)
log_max = np.log(upper) / np.log(10)
lspan = (log_max-log_min)*(1+padding[0]*2)
uspan = (log_max-log_min)*(1+padding[1]*2)
center = (log_min+log_max) / 2.0
start, end = np.power(10, center-lspan/2.), np.power(10, center+uspan/2.)
else:
span = (upper-lower)
lpad = span*(padding[0])
upad = span*(padding[1])
start, end = lower-lpad, upper+upad
else:
start, end = lower, upper
return start, end


def dimension_range(lower, upper, hard_range, soft_range, padding=None, log=False):
"""
Computes the range along a dimension by combining the data range
with the Dimension soft_range and range.
"""
lower, upper = max_range([(lower, upper), dimension.soft_range])
dmin, dmax = dimension.range
lower = dmin if isfinite(dmin) else lower
upper = dmax if isfinite(dmax) else upper
lower, upper = range_pad(lower, upper, padding, log)
lower, upper = max_range([(lower, upper), soft_range])
dmin, dmax = hard_range
lower = lower if dmin is None or not np.isfinite(dmin) else dmin
upper = upper if dmax is None or not np.isfinite(dmax) else dmax
return lower, upper


Expand Down Expand Up @@ -1035,6 +1079,7 @@ def dimension_sort(odict, kdims, vdims, key_index):
# Copied from param should make param version public
def is_number(obj):
if isinstance(obj, numbers.Number): return True
elif isinstance(obj, (np.str_, np.unicode_)): return False
# The extra check is for classes that behave like numbers, such as those
# found in numpy, gmpy, etc.
elif (hasattr(obj, '__int__') and hasattr(obj, '__add__')): return True
Expand Down Expand Up @@ -1671,8 +1716,7 @@ def validate_regular_sampling(values, rtol=10e-6):
Returns a boolean indicating whether the sampling is regular.
"""
diffs = np.diff(values)
vals = np.unique(diffs)
return not (len(vals) > 1 and np.abs(vals.min()-vals.max()) > abs(diffs.min()*rtol))
return (len(diffs) < 1) or abs(diffs.min()-diffs.max()) < abs(diffs.min()*rtol)


def compute_density(start, end, length, time_unit='us'):
Expand Down
6 changes: 4 additions & 2 deletions holoviews/element/chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ class ErrorBars(Chart):
bounds=(1, 3), constant=True)


def range(self, dim, data_range=True):
def range(self, dim, data_range=True, dimension_range=True):
didx = self.get_dimension_index(dim)
dim = self.get_dimension(dim)
if didx == 1 and data_range and len(self):
Expand All @@ -103,7 +103,9 @@ def range(self, dim, data_range=True):
pos_error = neg_error
lower = np.nanmin(mean-neg_error)
upper = np.nanmax(mean+pos_error)
return util.dimension_range(lower, upper, dim)
if not dimension_range:
return (lower, upper)
return util.dimension_range(lower, upper, dim.range, dim.soft_range)
return super(ErrorBars, self).range(dim, data_range)


Expand Down
8 changes: 4 additions & 4 deletions holoviews/element/graphs.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,14 +329,14 @@ def _split_edgepaths(self):
return self.edgepaths.clone(split_path(self.edgepaths))


def range(self, dimension, data_range=True):
def range(self, dimension, data_range=True, dimension_range=True):
if self.nodes and dimension in self.nodes.dimensions():
node_range = self.nodes.range(dimension, data_range)
node_range = self.nodes.range(dimension, data_range, dimension_range)
if self._edgepaths:
path_range = self._edgepaths.range(dimension, data_range)
path_range = self._edgepaths.range(dimension, data_range, dimension_range)
return max_range([node_range, path_range])
return node_range
return super(Graph, self).range(dimension, data_range)
return super(Graph, self).range(dimension, data_range, dimension_range)


def dimensions(self, selection='all', label=False):
Expand Down
23 changes: 12 additions & 11 deletions holoviews/element/raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from ..core import Dimension, Element2D, Overlay, Dataset
from ..core.boundingregion import BoundingRegion, BoundingBox
from ..core.sheetcoords import SheetCoordinateSystem, Slice
from ..core.util import dimension_range, compute_density, datetime_types
from .chart import Curve
from .graphs import TriMesh
from .tabular import Table
Expand Down Expand Up @@ -70,13 +69,15 @@ def __getitem__(self, slices):
extents=None)


def range(self, dim, data_range=True):
def range(self, dim, data_range=True, dimension_range=True):
idx = self.get_dimension_index(dim)
if data_range and idx == 2:
dimension = self.get_dimension(dim)
lower, upper = np.nanmin(self.data), np.nanmax(self.data)
return dimension_range(lower, upper, dimension)
return super(Raster, self).range(dim, data_range)
if not dimension_range:
return lower, upper
return util.dimension_range(lower, upper, dimension.range, dimension.soft_range)
return super(Raster, self).range(dim, data_range, dimension_range)


def dimension_values(self, dim, expanded=True, flat=True):
Expand Down Expand Up @@ -277,8 +278,8 @@ def __init__(self, data, kdims=None, vdims=None, bounds=None, extents=None,
if self.interface is ImageInterface and not isinstance(data, np.ndarray):
data_bounds = self.bounds.lbrt()
l, b, r, t = bounds.lbrt()
xdensity = xdensity if xdensity else compute_density(l, r, dim1, self._time_unit)
ydensity = ydensity if ydensity else compute_density(b, t, dim2, self._time_unit)
xdensity = xdensity if xdensity else util.compute_density(l, r, dim1, self._time_unit)
ydensity = ydensity if ydensity else util.compute_density(b, t, dim2, self._time_unit)
if not np.isfinite(xdensity) or not np.isfinite(ydensity):
raise ValueError('Density along Image axes could not be determined. '
'If the data contains only one coordinate along the '
Expand Down Expand Up @@ -329,9 +330,9 @@ def _validate(self, data_bounds, supplied_bounds):

not_close = False
for r, c in zip(bounds, self.bounds.lbrt()):
if isinstance(r, datetime_types):
if isinstance(r, util.datetime_types):
r = util.dt_to_int(r)
if isinstance(c, datetime_types):
if isinstance(c, util.datetime_types):
c = util.dt_to_int(c)
if util.isfinite(r) and not np.isclose(r, c, rtol=self.rtol):
not_close = True
Expand Down Expand Up @@ -521,7 +522,7 @@ def closest(self, coords=[], **kwargs):
return [getter(self.closest_cell_center(*el)) for el in coords]


def range(self, dim, data_range=True):
def range(self, dim, data_range=True, dimension_range=True):
idx = self.get_dimension_index(dim)
dimension = self.get_dimension(dim)
if idx in [0, 1] and data_range and dimension.range == (None, None):
Expand All @@ -531,11 +532,11 @@ def range(self, dim, data_range=True):
low, high = super(Image, self).range(dim, data_range)
density = self.ydensity if idx else self.xdensity
halfd = (1./density)/2.
if isinstance(low, datetime_types):
if isinstance(low, util.datetime_types):
halfd = np.timedelta64(int(round(halfd)), self._time_unit)
return (low-halfd, high+halfd)
else:
return super(Image, self).range(dim, data_range)
return super(Image, self).range(dim, data_range, dimension_range)


def table(self, datatype=None):
Expand Down
4 changes: 2 additions & 2 deletions holoviews/element/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ def __init__(self, data, kdims=None, vdims=None, **params):
self.vdims = process_dimensions(None, vdims)['vdims']


def range(self, dim, data_range=True):
def range(self, dim, data_range=True, dimension_range=True):
iskdim = self.get_dimension(dim) not in self.vdims
return super(StatisticsElement, self).range(dim, data_range=iskdim)
return super(StatisticsElement, self).range(dim, iskdim, dimension_range)


def dimension_values(self, dim, expanded=True, flat=True):
Expand Down
6 changes: 3 additions & 3 deletions holoviews/plotting/bokeh/annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def get_batched_data(self, element, ranges=None):
data[k].extend(eld)
return data, elmapping, style

def get_extents(self, element, ranges=None):
def get_extents(self, element, ranges=None, range_type='combined'):
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure where this should go, but one of the get_extents methods should mention that range_type can be 'data' or 'combined' (are there others?). I found out those are the two expected values by searching the code...

Copy link
Member Author

@philippjfr philippjfr Aug 22, 2018

Choose a reason for hiding this comment

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

I'm not sure where this should go, but one of the get_extents methods should mention that range_type can be 'data' or 'combined' (are there others?). I found out those are the two expected values by searching the code...

Presumably the baseclass should document this in detail, the full list includes:

  • 'data': Just the data ranges
  • 'combined': All the range types combined correctly
  • 'extents': Element.extents
  • 'soft': Dimension.soft_range values
  • 'hard': Dimension.range values

Copy link
Contributor

Choose a reason for hiding this comment

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

And there is 'soft' and 'hard'?

Copy link
Contributor

Choose a reason for hiding this comment

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

Again..github doesn't show your comments when you post them!

return None, None, None, None


Expand Down Expand Up @@ -139,7 +139,7 @@ def _init_glyph(self, plot, mapping, properties):
plot.renderers.append(box)
return None, box

def get_extents(self, element, ranges=None):
def get_extents(self, element, ranges=None, range_type='combined'):
return None, None, None, None


Expand Down Expand Up @@ -241,7 +241,7 @@ def _init_glyph(self, plot, mapping, properties, key):
plot.renderers.append(glyph)
return None, glyph

def get_extents(self, element, ranges=None):
def get_extents(self, element, ranges=None, range_type='combined'):
return None, None, None, None


Expand Down
Loading