diff options
Diffstat (limited to 'silx/gui/plot/_utils')
-rw-r--r-- | silx/gui/plot/_utils/dtime_ticklayout.py | 438 | ||||
-rw-r--r-- | silx/gui/plot/_utils/test/__init__.py | 4 | ||||
-rw-r--r-- | silx/gui/plot/_utils/test/testColormap.py | 648 | ||||
-rw-r--r-- | silx/gui/plot/_utils/test/test_dtime_ticklayout.py | 93 | ||||
-rw-r--r-- | silx/gui/plot/_utils/ticklayout.py | 85 |
5 files changed, 1243 insertions, 25 deletions
diff --git a/silx/gui/plot/_utils/dtime_ticklayout.py b/silx/gui/plot/_utils/dtime_ticklayout.py new file mode 100644 index 0000000..95fc235 --- /dev/null +++ b/silx/gui/plot/_utils/dtime_ticklayout.py @@ -0,0 +1,438 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module implements date-time labels layout on graph axes.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["P. Kenter"] +__license__ = "MIT" +__date__ = "04/04/2018" + + +import datetime as dt +import logging +import math +import time + +import dateutil.tz + +from dateutil.relativedelta import relativedelta + +from silx.third_party import enum +from .ticklayout import niceNumGeneric + +_logger = logging.getLogger(__name__) + + +MICROSECONDS_PER_SECOND = 1000000 +SECONDS_PER_MINUTE = 60 +SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE +SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR +SECONDS_PER_YEAR = 365.25 * SECONDS_PER_DAY +SECONDS_PER_MONTH_AVERAGE = SECONDS_PER_YEAR / 12 # Seconds per average month + + +# No dt.timezone in Python 2.7 so we use dateutil.tz.tzutc +_EPOCH = dt.datetime(1970, 1, 1, tzinfo=dateutil.tz.tzutc()) + +def timestamp(dtObj): + """ Returns POSIX timestamp of a datetime objects. + + If the dtObj object has a timestamp() method (python 3.3), this is + used. Otherwise (e.g. python 2.7) it is calculated here. + + The POSIX timestamp is a floating point value of the number of seconds + since the start of an epoch (typically 1970-01-01). For details see: + https://docs.python.org/3/library/datetime.html#datetime.datetime.timestamp + + :param datetime.datetime dtObj: date-time representation. + :return: POSIX timestamp + :rtype: float + """ + if hasattr(dtObj, "timestamp"): + return dtObj.timestamp() + else: + # Back ported from Python 3.5 + if dtObj.tzinfo is None: + return time.mktime((dtObj.year, dtObj.month, dtObj.day, + dtObj.hour, dtObj.minute, dtObj.second, + -1, -1, -1)) + dtObj.microsecond / 1e6 + else: + return (dtObj - _EPOCH).total_seconds() + + +@enum.unique +class DtUnit(enum.Enum): + YEARS = 0 + MONTHS = 1 + DAYS = 2 + HOURS = 3 + MINUTES = 4 + SECONDS = 5 + MICRO_SECONDS = 6 # a fraction of a second + + +def getDateElement(dateTime, unit): + """ Picks the date element with the unit from the dateTime + + E.g. getDateElement(datetime(1970, 5, 6), DtUnit.Day) will return 6 + + :param datetime dateTime: date/time to pick from + :param DtUnit unit: The unit describing the date element. + """ + if unit == DtUnit.YEARS: + return dateTime.year + elif unit == DtUnit.MONTHS: + return dateTime.month + elif unit == DtUnit.DAYS: + return dateTime.day + elif unit == DtUnit.HOURS: + return dateTime.hour + elif unit == DtUnit.MINUTES: + return dateTime.minute + elif unit == DtUnit.SECONDS: + return dateTime.second + elif unit == DtUnit.MICRO_SECONDS: + return dateTime.microsecond + else: + raise ValueError("Unexpected DtUnit: {}".format(unit)) + + +def setDateElement(dateTime, value, unit): + """ Returns a copy of dateTime with the tickStep unit set to value + + :param datetime.datetime: date time object + :param int value: value to set + :param DtUnit unit: unit + :return: datetime.datetime + """ + intValue = int(value) + _logger.debug("setDateElement({}, {} (int={}), {})" + .format(dateTime, value, intValue, unit)) + + year = dateTime.year + month = dateTime.month + day = dateTime.day + hour = dateTime.hour + minute = dateTime.minute + second = dateTime.second + microsecond = dateTime.microsecond + + if unit == DtUnit.YEARS: + year = intValue + elif unit == DtUnit.MONTHS: + month = intValue + elif unit == DtUnit.DAYS: + day = intValue + elif unit == DtUnit.HOURS: + hour = intValue + elif unit == DtUnit.MINUTES: + minute = intValue + elif unit == DtUnit.SECONDS: + second = intValue + elif unit == DtUnit.MICRO_SECONDS: + microsecond = intValue + else: + raise ValueError("Unexpected DtUnit: {}".format(unit)) + + _logger.debug("creating date time {}" + .format((year, month, day, hour, minute, second, microsecond))) + + return dt.datetime(year, month, day, hour, minute, second, microsecond, + tzinfo=dateTime.tzinfo) + + + +def roundToElement(dateTime, unit): + """ Returns a copy of dateTime with the + + :param datetime.datetime: date time object + :param DtUnit unit: unit + :return: datetime.datetime + """ + year = dateTime.year + month = dateTime.month + day = dateTime.day + hour = dateTime.hour + minute = dateTime.minute + second = dateTime.second + microsecond = dateTime.microsecond + + if unit.value < DtUnit.YEARS.value: + pass # Never round years + if unit.value < DtUnit.MONTHS.value: + month = 1 + if unit.value < DtUnit.DAYS.value: + day = 1 + if unit.value < DtUnit.HOURS.value: + hour = 0 + if unit.value < DtUnit.MINUTES.value: + minute = 0 + if unit.value < DtUnit.SECONDS.value: + second = 0 + if unit.value < DtUnit.MICRO_SECONDS.value: + microsecond = 0 + + result = dt.datetime(year, month, day, hour, minute, second, microsecond, + tzinfo=dateTime.tzinfo) + + return result + + +def addValueToDate(dateTime, value, unit): + """ Adds a value with unit to a dateTime. + + Uses dateutil.relativedelta.relativedelta from the standard library to do + the actual math. This function doesn't allow for fractional month or years, + so month and year are truncated to integers before adding. + + :param datetime dateTime: date time + :param float value: value to be added + :param DtUnit unit: of the value + :return: + """ + #logger.debug("addValueToDate({}, {}, {})".format(dateTime, value, unit)) + + if unit == DtUnit.YEARS: + intValue = int(value) # floats not implemented in relativeDelta(years) + return dateTime + relativedelta(years=intValue) + elif unit == DtUnit.MONTHS: + intValue = int(value) # floats not implemented in relativeDelta(mohths) + return dateTime + relativedelta(months=intValue) + elif unit == DtUnit.DAYS: + return dateTime + relativedelta(days=value) + elif unit == DtUnit.HOURS: + return dateTime + relativedelta(hours=value) + elif unit == DtUnit.MINUTES: + return dateTime + relativedelta(minutes=value) + elif unit == DtUnit.SECONDS: + return dateTime + relativedelta(seconds=value) + elif unit == DtUnit.MICRO_SECONDS: + return dateTime + relativedelta(microseconds=value) + else: + raise ValueError("Unexpected DtUnit: {}".format(unit)) + + +def bestUnit(durationInSeconds): + """ Gets the best tick spacing given a duration in seconds. + + :param durationInSeconds: time span duration in seconds + :return: DtUnit enumeration. + """ + + # Based on; https://stackoverflow.com/a/2144398/ + # If the duration is longer than two years the tick spacing will be in + # years. Else, if the duration is longer than two months, the spacing will + # be in months, Etcetera. + # + # This factor differs per unit. As a baseline it is 2, but for instance, + # for Months this needs to be higher (3>), This because it is impossible to + # have partial months so the tick spacing is always at least 1 month. A + # duration of two months would result in two ticks, which is too few. + # months would then results + + if durationInSeconds > SECONDS_PER_YEAR * 3: + return (durationInSeconds / SECONDS_PER_YEAR, DtUnit.YEARS) + elif durationInSeconds > SECONDS_PER_MONTH_AVERAGE * 3: + return (durationInSeconds / SECONDS_PER_MONTH_AVERAGE, DtUnit.MONTHS) + elif durationInSeconds > SECONDS_PER_DAY * 2: + return (durationInSeconds / SECONDS_PER_DAY, DtUnit.DAYS) + elif durationInSeconds > SECONDS_PER_HOUR * 2: + return (durationInSeconds / SECONDS_PER_HOUR, DtUnit.HOURS) + elif durationInSeconds > SECONDS_PER_MINUTE * 2: + return (durationInSeconds / SECONDS_PER_MINUTE, DtUnit.MINUTES) + elif durationInSeconds > 1 * 2: + return (durationInSeconds, DtUnit.SECONDS) + else: + return (durationInSeconds * MICROSECONDS_PER_SECOND, + DtUnit.MICRO_SECONDS) + + +NICE_DATE_VALUES = { + DtUnit.YEARS: [1, 2, 5, 10], + DtUnit.MONTHS: [1, 2, 3, 4, 6, 12], + DtUnit.DAYS: [1, 2, 3, 7, 14, 28], + DtUnit.HOURS: [1, 2, 3, 4, 6, 12], + DtUnit.MINUTES: [1, 2, 3, 5, 10, 15, 30], + DtUnit.SECONDS: [1, 2, 3, 5, 10, 15, 30], + DtUnit.MICRO_SECONDS : [1.0, 2.0, 5.0, 10.0], # floats for microsec +} + + +def bestFormatString(spacing, unit): + """ Finds the best format string given the spacing and DtUnit. + + If the spacing is a fractional number < 1 the format string will take this + into account + + :param spacing: spacing between ticks + :param DtUnit unit: + :return: Format string for use in strftime + :rtype: str + """ + isSmall = spacing < 1 + + if unit == DtUnit.YEARS: + return "%Y-m" if isSmall else "%Y" + elif unit == DtUnit.MONTHS: + return "%Y-%m-%d" if isSmall else "%Y-%m" + elif unit == DtUnit.DAYS: + return "%H:%M" if isSmall else "%Y-%m-%d" + elif unit == DtUnit.HOURS: + return "%H:%M" if isSmall else "%H:%M" + elif unit == DtUnit.MINUTES: + return "%H:%M:%S" if isSmall else "%H:%M" + elif unit == DtUnit.SECONDS: + return "%S.%f" if isSmall else "%H:%M:%S" + elif unit == DtUnit.MICRO_SECONDS: + return "%S.%f" + else: + raise ValueError("Unexpected DtUnit: {}".format(unit)) + + +def niceDateTimeElement(value, unit, isRound=False): + """ Uses the Nice Numbers algorithm to determine a nice value. + + The fractions are optimized for the unit of the date element. + """ + + niceValues = NICE_DATE_VALUES[unit] + elemValue = niceNumGeneric(value, niceValues, isRound=isRound) + + if unit == DtUnit.YEARS or unit == DtUnit.MONTHS: + elemValue = max(1, int(elemValue)) + + return elemValue + + +def findStartDate(dMin, dMax, nTicks): + """ Rounds a date down to the nearest nice number of ticks + """ + assert dMax > dMin, \ + "dMin ({}) should come before dMax ({})".format(dMin, dMax) + + delta = dMax - dMin + lengthSec = delta.total_seconds() + _logger.debug("findStartDate: {}, {} (duration = {} sec, {} days)" + .format(dMin, dMax, lengthSec, lengthSec / SECONDS_PER_DAY)) + + length, unit = bestUnit(delta.total_seconds()) + niceLength = niceDateTimeElement(length, unit) + + _logger.debug("Length: {:8.3f} {} (nice = {})" + .format(length, unit.name, niceLength)) + + niceSpacing = niceDateTimeElement(niceLength / nTicks, unit, isRound=True) + + _logger.debug("Spacing: {:8.3f} {} (nice = {})" + .format(niceLength / nTicks, unit.name, niceSpacing)) + + dVal = getDateElement(dMin, unit) + + if unit == DtUnit.MONTHS: # TODO: better rounding? + niceVal = math.floor((dVal-1) / niceSpacing) * niceSpacing + 1 + elif unit == DtUnit.DAYS: + niceVal = math.floor((dVal-1) / niceSpacing) * niceSpacing + 1 + else: + niceVal = math.floor(dVal / niceSpacing) * niceSpacing + + _logger.debug("StartValue: dVal = {}, niceVal: {} ({})" + .format(dVal, niceVal, unit.name)) + + startDate = roundToElement(dMin, unit) + startDate = setDateElement(startDate, niceVal, unit) + + return startDate, niceSpacing, unit + + +def dateRange(dMin, dMax, step, unit, includeFirstBeyond = False): + """ Generates a range of dates + + :param datetime dMin: start date + :param datetime dMax: end date + :param int step: the step size + :param DtUnit unit: the unit of the step size + :param bool includeFirstBeyond: if True the first date later than dMax will + be included in the range. If False (the default), the last generated + datetime will always be smaller than dMax. + :return: + """ + if (unit == DtUnit.YEARS or unit == DtUnit.MONTHS or + unit == DtUnit.MICRO_SECONDS): + + # Month and years will be converted to integers + assert int(step) > 0, "Integer value or tickstep is 0" + else: + assert step > 0, "tickstep is 0" + + dateTime = dMin + while dateTime < dMax: + yield dateTime + dateTime = addValueToDate(dateTime, step, unit) + + if includeFirstBeyond: + yield dateTime + + + +def calcTicks(dMin, dMax, nTicks): + """Returns tick positions. + + :param datetime.datetime dMin: The min value on the axis + :param datetime.datetime dMax: The max value on the axis + :param int nTicks: The target number of ticks. The actual number of found + ticks may differ. + :returns: (list of datetimes, DtUnit) tuple + """ + _logger.debug("Calc calcTicks({}, {}, nTicks={})" + .format(dMin, dMax, nTicks)) + + startDate, niceSpacing, unit = findStartDate(dMin, dMax, nTicks) + + result = [] + for d in dateRange(startDate, dMax, niceSpacing, unit, + includeFirstBeyond=True): + result.append(d) + + assert result[0] <= dMin, \ + "First nice date ({}) should be <= dMin {}".format(result[0], dMin) + + assert result[-1] >= dMax, \ + "Last nice date ({}) should be >= dMax {}".format(result[-1], dMax) + + return result, niceSpacing, unit + + +def calcTicksAdaptive(dMin, dMax, axisLength, tickDensity): + """ Calls calcTicks with a variable number of ticks, depending on axisLength + """ + # At least 2 ticks + nticks = max(2, int(round(tickDensity * axisLength))) + return calcTicks(dMin, dMax, nticks) + + + + + diff --git a/silx/gui/plot/_utils/test/__init__.py b/silx/gui/plot/_utils/test/__init__.py index 4a443ac..624dbcb 100644 --- a/silx/gui/plot/_utils/test/__init__.py +++ b/silx/gui/plot/_utils/test/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# Copyright (c) 2016-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -32,10 +32,12 @@ __date__ = "18/10/2016" import unittest +from .test_dtime_ticklayout import suite as test_dtime_ticklayout_suite from .test_ticklayout import suite as test_ticklayout_suite def suite(): testsuite = unittest.TestSuite() + testsuite.addTest(test_dtime_ticklayout_suite()) testsuite.addTest(test_ticklayout_suite()) return testsuite diff --git a/silx/gui/plot/_utils/test/testColormap.py b/silx/gui/plot/_utils/test/testColormap.py new file mode 100644 index 0000000..d77fa65 --- /dev/null +++ b/silx/gui/plot/_utils/test/testColormap.py @@ -0,0 +1,648 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ + +import logging +import time +import unittest + +import numpy +from PyMca5 import spslut + +from silx.image.colormap import dataToRGBAColormap + +_logger = logging.getLogger(__name__) + +# TODOs: +# what to do with max < min: as SPS LUT or also invert outside boundaries? +# test usedMin and usedMax +# benchmark + + +# common ###################################################################### + +class _TestColormap(unittest.TestCase): + # Array data types to test + FLOATING_DTYPES = numpy.float16, numpy.float32, numpy.float64 + SIGNED_DTYPES = FLOATING_DTYPES + (numpy.int8, numpy.int16, + numpy.int32, numpy.int64) + UNSIGNED_DTYPES = numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64 + DTYPES = SIGNED_DTYPES + UNSIGNED_DTYPES + + # Array sizes to test + SIZES = 2, 10, 256, 1024 # , 2048, 4096 + + # Colormaps definitions + _LUT_RED_256 = numpy.zeros((256, 4), dtype=numpy.uint8) + _LUT_RED_256[:, 0] = numpy.arange(256, dtype=numpy.uint8) + _LUT_RED_256[:, 3] = 255 + + _LUT_RGB_3 = numpy.array(((255, 0, 0, 255), + (0, 255, 0, 255), + (0, 0, 255, 255)), dtype=numpy.uint8) + + _LUT_RGB_768 = numpy.zeros((768, 4), dtype=numpy.uint8) + _LUT_RGB_768[0:256, 0] = numpy.arange(256, dtype=numpy.uint8) + _LUT_RGB_768[256:512, 1] = numpy.arange(256, dtype=numpy.uint8) + _LUT_RGB_768[512:768, 1] = numpy.arange(256, dtype=numpy.uint8) + _LUT_RGB_768[:, 3] = 255 + + COLORMAPS = { + 'red 256': _LUT_RED_256, + 'rgb 3': _LUT_RGB_3, + 'rgb 768': _LUT_RGB_768, + } + + @staticmethod + def _log(*args): + """Logging used by test for debugging.""" + _logger.debug(str(args)) + + @staticmethod + def buildControlPixmap(data, colormap, start=None, end=None, + isLog10=False): + """Generate a pixmap used to test C pixmap.""" + if isLog10: # Convert to log + if start is None: + posValue = data[numpy.nonzero(data > 0)] + if posValue.size != 0: + start = numpy.nanmin(posValue) + else: + start = 0. + + if end is None: + end = numpy.nanmax(data) + + start = 0. if start <= 0. else numpy.log10(start, + dtype=numpy.float64) + end = 0. if end <= 0. else numpy.log10(end, + dtype=numpy.float64) + + data = numpy.log10(data, dtype=numpy.float64) + else: + if start is None: + start = numpy.nanmin(data) + if end is None: + end = numpy.nanmax(data) + + start, end = float(start), float(end) + min_, max_ = min(start, end), max(start, end) + + if start == end: + indices = numpy.asarray((len(colormap) - 1) * (data >= max_), + dtype=numpy.int) + else: + clipData = numpy.clip(data, min_, max_) # Clip first avoid overflow + scale = len(colormap) / (end - start) + normData = scale * (numpy.asarray(clipData, numpy.float64) - start) + + # Clip again to makes sure <= len(colormap) - 1 + indices = numpy.asarray(numpy.clip(normData, + 0, len(colormap) - 1), + dtype=numpy.uint32) + + pixmap = numpy.take(colormap, indices, axis=0) + pixmap.shape = data.shape + (4,) + return numpy.ascontiguousarray(pixmap) + + @staticmethod + def buildSPSLUTRedPixmap(data, start=None, end=None, isLog10=False): + """Generate a pixmap with SPS LUT. + Only supports red colormap with 256 colors. + """ + colormap = spslut.RED + mapping = spslut.LOG if isLog10 else spslut.LINEAR + + if start is None and end is None: + autoScale = 1 + start, end = 0, 1 + else: + autoScale = 0 + if start is None: + start = data.min() + if end is None: + end = data.max() + + pixmap, size, minMax = spslut.transform(data, + (1, 0), + (mapping, 3.0), + 'RGBX', + colormap, + autoScale, + (start, end), + (0, 255), + 1) + pixmap.shape = data.shape[0], data.shape[1], 4 + + return pixmap + + def _testColormap(self, data, colormap, start, end, control=None, + isLog10=False, nanColor=None): + """Test pixmap built with C code against SPS LUT if possible, + else against Python control code.""" + startTime = time.time() + pixmap = dataToRGBAColormap(data, + colormap, + start, + end, + isLog10, + nanColor) + duration = time.time() - startTime + + # Compare with result + controlType = 'array' + if control is None: + startTime = time.time() + + # Compare with SPS LUT if possible + if (colormap.shape == self.COLORMAPS['red 256'].shape and + numpy.all(numpy.equal(colormap, self.COLORMAPS['red 256'])) and + data.size % 2 == 0 and + data.dtype in (numpy.float32, numpy.float64)): + # Only works with red colormap and even size + # as it needs 2D data + if len(data.shape) == 1: + data.shape = data.size // 2, -1 + pixmap.shape = data.shape + (4,) + control = self.buildSPSLUTRedPixmap(data, start, end, isLog10) + controlType = 'SPS LUT' + + # Compare with python test implementation + else: + control = self.buildControlPixmap(data, colormap, start, end, + isLog10) + controlType = 'Python control code' + + controlDuration = time.time() - startTime + if duration >= controlDuration: + self._log('duration', duration, 'control', controlDuration) + # Allows duration to be 20% over SPS LUT duration + # self.assertTrue(duration < 1.2 * controlDuration) + + difference = numpy.fabs(numpy.asarray(pixmap, dtype=numpy.float64) - + numpy.asarray(control, dtype=numpy.float64)) + if numpy.any(difference != 0.0): + self._log('control', controlType) + self._log('data', data) + self._log('pixmap', pixmap) + self._log('control', control) + self._log('errors', numpy.ravel(difference)) + self._log('errors', difference[difference != 0]) + self._log('in pixmap', pixmap[difference != 0]) + self._log('in control', control[difference != 0]) + self._log('Max error', difference.max()) + + # Allows a difference of 1 per channel + self.assertTrue(numpy.all(difference <= 1.0)) + + return duration + + +# TestColormap ################################################################ + +class TestColormap(_TestColormap): + """Test common limit case for colormap in C with both linear and log mode. + + Test with different: data types, sizes, colormaps (with different sizes), + mapping range. + """ + + def testNoData(self): + """Test pixmap generation with empty data.""" + self._log("TestColormap.testNoData") + cmapName = 'red 256' + colormap = self.COLORMAPS[cmapName] + + for dtype in self.DTYPES: + for isLog10 in (False, True): + data = numpy.array((), dtype=dtype) + result = numpy.array((), dtype=numpy.uint8) + result.shape = 0, 4 + duration = self._testColormap(data, colormap, + None, None, result, isLog10) + self._log('No data', 'red 256', dtype, len(data), (None, None), + 'isLog10:', isLog10, duration) + + def testNaN(self): + """Test pixmap generation with NaN values and no NaN color.""" + self._log("TestColormap.testNaN") + cmapName = 'red 256' + colormap = self.COLORMAPS[cmapName] + + for dtype in self.FLOATING_DTYPES: + for isLog10 in (False, True): + # All NaNs + data = numpy.array((float('nan'),) * 4, dtype=dtype) + result = numpy.array(((0, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, colormap, + None, None, result, isLog10) + self._log('All NaNs', 'red 256', dtype, len(data), + (None, None), 'isLog10:', isLog10, duration) + + # Some NaNs + data = numpy.array((1., float('nan'), 0., float('nan')), + dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, colormap, + None, None, result, isLog10) + self._log('Some NaNs', 'red 256', dtype, len(data), + (None, None), 'isLog10:', isLog10, duration) + + def testNaNWithColor(self): + """Test pixmap generation with NaN values with a NaN color.""" + self._log("TestColormap.testNaNWithColor") + cmapName = 'red 256' + colormap = self.COLORMAPS[cmapName] + + for dtype in self.FLOATING_DTYPES: + for isLog10 in (False, True): + # All NaNs + data = numpy.array((float('nan'),) * 4, dtype=dtype) + result = numpy.array(((128, 128, 128, 255), + (128, 128, 128, 255), + (128, 128, 128, 255), + (128, 128, 128, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, colormap, + None, None, result, isLog10, + nanColor=(128, 128, 128, 255)) + self._log('All NaNs', 'red 256', dtype, len(data), + (None, None), 'isLog10:', isLog10, duration) + + # Some NaNs + data = numpy.array((1., float('nan'), 0., float('nan')), + dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (128, 128, 128, 255), + (0, 0, 0, 255), + (128, 128, 128, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, colormap, + None, None, result, isLog10, + nanColor=(128, 128, 128, 255)) + self._log('Some NaNs', 'red 256', dtype, len(data), + (None, None), 'isLog10:', isLog10, duration) + + +# TestLinearColormap ########################################################## + +class TestLinearColormap(_TestColormap): + """Test fill pixmap with colormap in C with linear mode. + + Test with different: data types, sizes, colormaps (with different sizes), + mapping range. + """ + + # Colormap ranges to map + RANGES = (None, None), (1, 10) + + def test1DData(self): + """Test pixmap generation for 1D data of different size and types.""" + self._log("TestLinearColormap.test1DData") + for cmapName, colormap in self.COLORMAPS.items(): + for size in self.SIZES: + for dtype in self.DTYPES: + for start, end in self.RANGES: + # Increasing values + data = numpy.arange(size, dtype=dtype) + duration = self._testColormap(data, colormap, + start, end) + + self._log('1D', cmapName, dtype, size, (start, end), + duration) + + # Reverse order + data = data[::-1] + duration = self._testColormap(data, colormap, + start, end) + + self._log('1D', cmapName, dtype, size, (start, end), + duration) + + def test2DData(self): + """Test pixmap generation for 2D data of different size and types.""" + self._log("TestLinearColormap.test2DData") + for cmapName, colormap in self.COLORMAPS.items(): + for size in self.SIZES: + for dtype in self.DTYPES: + for start, end in self.RANGES: + # Increasing values + data = numpy.arange(size * size, dtype=dtype) + data = numpy.nan_to_num(data) + data.shape = size, size + duration = self._testColormap(data, colormap, + start, end) + + self._log('2D', cmapName, dtype, size, (start, end), + duration) + + # Reverse order + data = data[::-1, ::-1] + duration = self._testColormap(data, colormap, + start, end) + + self._log('2D', cmapName, dtype, size, (start, end), + duration) + + def testInf(self): + """Test pixmap generation with Inf values.""" + self._log("TestLinearColormap.testInf") + + for dtype in self.FLOATING_DTYPES: + # All positive Inf + data = numpy.array((float('inf'),) * 4, dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (255, 0, 0, 255), + (255, 0, 0, 255), + (255, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, self.COLORMAPS['red 256'], + None, None, result) + self._log('All +Inf', 'red 256', dtype, len(data), (None, None), + duration) + + # All negative Inf + data = numpy.array((float('-inf'),) * 4, dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (255, 0, 0, 255), + (255, 0, 0, 255), + (255, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, self.COLORMAPS['red 256'], + None, None, result) + self._log('All -Inf', 'red 256', dtype, len(data), (None, None), + duration) + + # All +/-Inf + data = numpy.array((float('inf'), float('-inf'), + float('-inf'), float('inf')), dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255), + (255, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, self.COLORMAPS['red 256'], + None, None, result) + self._log('All +/-Inf', 'red 256', dtype, len(data), (None, None), + duration) + + # Some +/-Inf + data = numpy.array((float('inf'), 0., float('-inf'), -10.), + dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, self.COLORMAPS['red 256'], + None, None, + result) # Seg Fault with SPS + self._log('Some +/-Inf', 'red 256', dtype, len(data), (None, None), + duration) + + @unittest.skip("Not for reproductible tests") + def test1DDataRandom(self): + """Test pixmap generation for 1D data of different size and types.""" + self._log("TestLinearColormap.test1DDataRandom") + for cmapName, colormap in self.COLORMAPS.items(): + for size in self.SIZES: + for dtype in self.DTYPES: + for start, end in self.RANGES: + try: + dtypeMax = numpy.iinfo(dtype).max + except ValueError: + dtypeMax = numpy.finfo(dtype).max + data = numpy.asarray(numpy.random.rand(size) * dtypeMax, + dtype=dtype) + duration = self._testColormap(data, colormap, + start, end) + + self._log('1D Random', cmapName, dtype, size, + (start, end), duration) + + +# TestLog10Colormap ########################################################### + +class TestLog10Colormap(_TestColormap): + """Test fill pixmap with colormap in C with log mode. + + Test with different: data types, sizes, colormaps (with different sizes), + mapping range. + """ + # Colormap ranges to map + RANGES = (None, None), (1, 10) # , (10, 1) + + def test1DDataAllPositive(self): + """Test pixmap generation for all positive 1D data.""" + self._log("TestLog10Colormap.test1DDataAllPositive") + for cmapName, colormap in self.COLORMAPS.items(): + for size in self.SIZES: + for dtype in self.DTYPES: + for start, end in self.RANGES: + # Increasing values + data = numpy.arange(size, dtype=dtype) + 1 + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('1D', cmapName, dtype, size, (start, end), + duration) + + # Reverse order + data = data[::-1] + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('1D', cmapName, dtype, size, (start, end), + duration) + + def test2DDataAllPositive(self): + """Test pixmap generation for all positive 2D data.""" + self._log("TestLog10Colormap.test2DDataAllPositive") + for cmapName, colormap in self.COLORMAPS.items(): + for size in self.SIZES: + for dtype in self.DTYPES: + for start, end in self.RANGES: + # Increasing values + data = numpy.arange(size * size, dtype=dtype) + 1 + data = numpy.nan_to_num(data) + data.shape = size, size + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('2D', cmapName, dtype, size, (start, end), + duration) + + # Reverse order + data = data[::-1, ::-1] + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('2D', cmapName, dtype, size, (start, end), + duration) + + def testAllNegative(self): + """Test pixmap generation for all negative 1D data.""" + self._log("TestLog10Colormap.testAllNegative") + for cmapName, colormap in self.COLORMAPS.items(): + for size in self.SIZES: + for dtype in self.SIGNED_DTYPES: + for start, end in self.RANGES: + # Increasing values + data = numpy.arange(-size, 0, dtype=dtype) + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('1D', cmapName, dtype, size, (start, end), + duration) + + # Reverse order + data = data[::-1] + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('1D', cmapName, dtype, size, (start, end), + duration) + + def testCrossingZero(self): + """Test pixmap generation for 1D data with negative and zero.""" + self._log("TestLog10Colormap.testCrossingZero") + for cmapName, colormap in self.COLORMAPS.items(): + for size in self.SIZES: + for dtype in self.SIGNED_DTYPES: + for start, end in self.RANGES: + # Increasing values + data = numpy.arange(-size/2, size/2 + 1, dtype=dtype) + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('1D', cmapName, dtype, size, (start, end), + duration) + + # Reverse order + data = data[::-1] + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('1D', cmapName, dtype, size, (start, end), + duration) + + @unittest.skip("Not for reproductible tests") + def test1DDataRandom(self): + """Test pixmap generation for 1D data of different size and types.""" + self._log("TestLog10Colormap.test1DDataRandom") + for cmapName, colormap in self.COLORMAPS.items(): + for size in self.SIZES: + for dtype in self.DTYPES: + for start, end in self.RANGES: + try: + dtypeMax = numpy.iinfo(dtype).max + dtypeMin = numpy.iinfo(dtype).min + except ValueError: + dtypeMax = numpy.finfo(dtype).max + dtypeMin = numpy.finfo(dtype).min + if dtypeMin < 0: + data = numpy.asarray(-dtypeMax/2. + + numpy.random.rand(size) * dtypeMax, + dtype=dtype) + else: + data = numpy.asarray(numpy.random.rand(size) * dtypeMax, + dtype=dtype) + + duration = self._testColormap(data, colormap, + start, end, + isLog10=True) + + self._log('1D Random', cmapName, dtype, size, + (start, end), duration) + + def testInf(self): + """Test pixmap generation with Inf values.""" + self._log("TestLog10Colormap.testInf") + + for dtype in self.FLOATING_DTYPES: + # All positive Inf + data = numpy.array((float('inf'),) * 4, dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (255, 0, 0, 255), + (255, 0, 0, 255), + (255, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, self.COLORMAPS['red 256'], + None, None, result, isLog10=True) + self._log('All +Inf', 'red 256', dtype, len(data), (None, None), + duration) + + # All negative Inf + data = numpy.array((float('-inf'),) * 4, dtype=dtype) + result = numpy.array(((0, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, self.COLORMAPS['red 256'], + None, None, result, isLog10=True) + self._log('All -Inf', 'red 256', dtype, len(data), (None, None), + duration) + + # All +/-Inf + data = numpy.array((float('inf'), float('-inf'), + float('-inf'), float('inf')), dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255), + (255, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, self.COLORMAPS['red 256'], + None, None, result, isLog10=True) + self._log('All +/-Inf', 'red 256', dtype, len(data), (None, None), + duration) + + # Some +/-Inf + data = numpy.array((float('inf'), 0., float('-inf'), -10.), + dtype=dtype) + result = numpy.array(((255, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255), + (0, 0, 0, 255)), dtype=numpy.uint8) + duration = self._testColormap(data, self.COLORMAPS['red 256'], + None, None, result, isLog10=True) + self._log('Some +/-Inf', 'red 256', dtype, len(data), (None, None), + duration) + + +def suite(): + testSuite = unittest.TestSuite() + for testClass in (TestColormap, TestLinearColormap): # , TestLog10Colormap): + testSuite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(testClass)) + return testSuite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/plot/_utils/test/test_dtime_ticklayout.py b/silx/gui/plot/_utils/test/test_dtime_ticklayout.py new file mode 100644 index 0000000..2b87148 --- /dev/null +++ b/silx/gui/plot/_utils/test/test_dtime_ticklayout.py @@ -0,0 +1,93 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["P. Kenter"] +__license__ = "MIT" +__date__ = "06/04/2018" + + +import datetime as dt +import unittest + + +from silx.gui.plot._utils.dtime_ticklayout import ( + calcTicks, DtUnit, SECONDS_PER_YEAR) + + +class DtTestTickLayout(unittest.TestCase): + """Test ticks layout algorithms""" + + def testSmallMonthlySpacing(self): + """ Tests a range that did result in a spacing of less than 1 month. + It is impossible to add fractional month so the unit must be in days + """ + from dateutil import parser + d1 = parser.parse("2017-01-03 13:15:06.000044") + d2 = parser.parse("2017-03-08 09:16:16.307584") + _ticks, _units, spacing = calcTicks(d1, d2, nTicks=4) + + self.assertEqual(spacing, DtUnit.DAYS) + + + def testNoCrash(self): + """ Creates many combinations of and number-of-ticks and end-dates; + tests that it doesn't give an exception and returns a reasonable number + of ticks. + """ + d1 = dt.datetime(2017, 1, 3, 13, 15, 6, 44) + + value = 100e-6 # Start at 100 micro sec range. + + while value <= 200 * SECONDS_PER_YEAR: + + d2 = d1 + dt.timedelta(microseconds=value*1e6) # end date range + + for numTicks in range(2, 12): + ticks, _, _ = calcTicks(d1, d2, numTicks) + + margin = 2.5 + self.assertTrue( + numTicks/margin <= len(ticks) <= numTicks*margin, + "Condition {} <= {} <= {} failed for # ticks={} and d2={}:" + .format(numTicks/margin, len(ticks), numTicks * margin, + numTicks, d2)) + + value = value * 1.5 # let date period grow exponentially + + + + + +def suite(): + testsuite = unittest.TestSuite() + testsuite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(DtTestTickLayout)) + return testsuite + + +if __name__ == '__main__': + unittest.main() diff --git a/silx/gui/plot/_utils/ticklayout.py b/silx/gui/plot/_utils/ticklayout.py index 6e9f654..c9fd3e6 100644 --- a/silx/gui/plot/_utils/ticklayout.py +++ b/silx/gui/plot/_utils/ticklayout.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# Copyright (c) 2014-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -51,28 +51,65 @@ def numberOfDigits(tickSpacing): # Nice Numbers ################################################################ -def _niceNum(value, isRound=False): - expvalue = math.floor(math.log10(value)) - frac = value/pow(10., expvalue) - if isRound: - if frac < 1.5: - nicefrac = 1. - elif frac < 3.: - nicefrac = 2. - elif frac < 7.: - nicefrac = 5. - else: - nicefrac = 10. +# This is the original niceNum implementation. For the date time ticks a more +# generic implementation was needed. +# +# def _niceNum(value, isRound=False): +# expvalue = math.floor(math.log10(value)) +# frac = value/pow(10., expvalue) +# if isRound: +# if frac < 1.5: +# nicefrac = 1. +# elif frac < 3.: # In niceNumGeneric this is (2+5)/2 = 3.5 +# nicefrac = 2. +# elif frac < 7.: +# nicefrac = 5. # In niceNumGeneric this is (5+10)/2 = 7.5 +# else: +# nicefrac = 10. +# else: +# if frac <= 1.: +# nicefrac = 1. +# elif frac <= 2.: +# nicefrac = 2. +# elif frac <= 5.: +# nicefrac = 5. +# else: +# nicefrac = 10. +# return nicefrac * pow(10., expvalue) + + +def niceNumGeneric(value, niceFractions=None, isRound=False): + """ A more generic implementation of the _niceNum function + + Allows the user to specify the fractions instead of using a hardcoded + list of [1, 2, 5, 10.0]. + """ + if value == 0: + return value + + if niceFractions is None: # Use default values + niceFractions = 1., 2., 5., 10. + roundFractions = (1.5, 3., 7., 10.) if isRound else niceFractions + else: - if frac <= 1.: - nicefrac = 1. - elif frac <= 2.: - nicefrac = 2. - elif frac <= 5.: - nicefrac = 5. - else: - nicefrac = 10. - return nicefrac * pow(10., expvalue) + roundFractions = list(niceFractions) + if isRound: + # Take the average with the next element. The last remains the same. + for i in range(len(roundFractions) - 1): + roundFractions[i] = (niceFractions[i] + niceFractions[i+1]) / 2 + + highest = niceFractions[-1] + value = float(value) + + expvalue = math.floor(math.log(value, highest)) + frac = value / pow(highest, expvalue) + + for niceFrac, roundFrac in zip(niceFractions, roundFractions): + if frac <= roundFrac: + return niceFrac * pow(highest, expvalue) + + # should not come here + assert False, "should not come here" def niceNumbers(vMin, vMax, nTicks=5): @@ -89,8 +126,8 @@ def niceNumbers(vMin, vMax, nTicks=5): number of fractional digit to show :rtype: tuple """ - vrange = _niceNum(vMax - vMin, False) - spacing = _niceNum(vrange / nTicks, True) + vrange = niceNumGeneric(vMax - vMin, isRound=False) + spacing = niceNumGeneric(vrange / nTicks, isRound=True) graphmin = math.floor(vMin / spacing) * spacing graphmax = math.ceil(vMax / spacing) * spacing nfrac = numberOfDigits(spacing) |