summaryrefslogtreecommitdiff
path: root/silx/gui/plot/_utils
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/plot/_utils')
-rw-r--r--silx/gui/plot/_utils/dtime_ticklayout.py438
-rw-r--r--silx/gui/plot/_utils/test/__init__.py4
-rw-r--r--silx/gui/plot/_utils/test/testColormap.py648
-rw-r--r--silx/gui/plot/_utils/test/test_dtime_ticklayout.py93
-rw-r--r--silx/gui/plot/_utils/ticklayout.py85
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)