summaryrefslogtreecommitdiff
path: root/src/silx/gui/plot/_utils/dtime_ticklayout.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/silx/gui/plot/_utils/dtime_ticklayout.py')
-rw-r--r--src/silx/gui/plot/_utils/dtime_ticklayout.py162
1 files changed, 101 insertions, 61 deletions
diff --git a/src/silx/gui/plot/_utils/dtime_ticklayout.py b/src/silx/gui/plot/_utils/dtime_ticklayout.py
index 3c355d7..ba0fda7 100644
--- a/src/silx/gui/plot/_utils/dtime_ticklayout.py
+++ b/src/silx/gui/plot/_utils/dtime_ticklayout.py
@@ -1,6 +1,6 @@
# /*##########################################################################
#
-# Copyright (c) 2014-2022 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2023 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
@@ -21,6 +21,8 @@
# THE SOFTWARE.
#
# ###########################################################################*/
+from __future__ import annotations
+
"""This module implements date-time labels layout on graph axes."""
__authors__ = ["P. Kenter"]
@@ -28,6 +30,7 @@ __license__ = "MIT"
__date__ = "04/04/2018"
+from collections.abc import Sequence
import datetime as dt
import enum
import logging
@@ -48,14 +51,15 @@ 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
+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.
+ """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.
@@ -73,9 +77,22 @@ def timestamp(dtObj):
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
+ 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()
@@ -92,7 +109,7 @@ class DtUnit(enum.Enum):
def getDateElement(dateTime, unit):
- """ Picks the date element with the unit from the dateTime
+ """Picks the date element with the unit from the dateTime
E.g. getDateElement(datetime(1970, 5, 6), DtUnit.Day) will return 6
@@ -118,7 +135,7 @@ def getDateElement(dateTime, unit):
def setDateElement(dateTime, value, unit):
- """ Returns a copy of dateTime with the tickStep unit set to value
+ """Returns a copy of dateTime with the tickStep unit set to value
:param datetime.datetime: date time object
:param int value: value to set
@@ -126,8 +143,9 @@ def setDateElement(dateTime, value, unit):
:return: datetime.datetime
"""
intValue = int(value)
- _logger.debug("setDateElement({}, {} (int={}), {})"
- .format(dateTime, value, intValue, unit))
+ _logger.debug(
+ "setDateElement({}, {} (int={}), {})".format(dateTime, value, intValue, unit)
+ )
year = dateTime.year
month = dateTime.month
@@ -154,16 +172,19 @@ def setDateElement(dateTime, value, unit):
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)
+ _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 rounded to given unit
+ """Returns a copy of dateTime rounded to given unit
:param datetime.datetime: date time object
:param DtUnit unit: unit
@@ -178,7 +199,7 @@ def roundToElement(dateTime, unit):
microsecond = dateTime.microsecond
if unit.value < DtUnit.YEARS.value:
- pass # Never round years
+ pass # Never round years
if unit.value < DtUnit.MONTHS.value:
month = 1
if unit.value < DtUnit.DAYS.value:
@@ -192,14 +213,15 @@ def roundToElement(dateTime, unit):
if unit.value < DtUnit.MICRO_SECONDS.value:
microsecond = 0
- result = dt.datetime(year, month, day, hour, minute, second, microsecond,
- tzinfo=dateTime.tzinfo)
+ 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.
+ """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,
@@ -211,13 +233,13 @@ def addValueToDate(dateTime, value, unit):
:return:
:raises ValueError: unit is unsupported or result is out of datetime bounds
"""
- #logger.debug("addValueToDate({}, {}, {})".format(dateTime, value, unit))
+ # logger.debug("addValueToDate({}, {}, {})".format(dateTime, value, unit))
if unit == DtUnit.YEARS:
- intValue = int(value) # floats not implemented in relativeDelta(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)
+ intValue = int(value) # floats not implemented in relativeDelta(mohths)
return dateTime + relativedelta(months=intValue)
elif unit == DtUnit.DAYS:
return dateTime + relativedelta(days=value)
@@ -234,7 +256,7 @@ def addValueToDate(dateTime, value, unit):
def bestUnit(durationInSeconds):
- """ Gets the best tick spacing given a duration in seconds.
+ """Gets the best tick spacing given a duration in seconds.
:param durationInSeconds: time span duration in seconds
:return: DtUnit enumeration.
@@ -264,8 +286,7 @@ def bestUnit(durationInSeconds):
elif durationInSeconds > 1 * 2:
return (durationInSeconds, DtUnit.SECONDS)
else:
- return (durationInSeconds * MICROSECONDS_PER_SECOND,
- DtUnit.MICRO_SECONDS)
+ return (durationInSeconds * MICROSECONDS_PER_SECOND, DtUnit.MICRO_SECONDS)
NICE_DATE_VALUES = {
@@ -275,12 +296,12 @@ NICE_DATE_VALUES = {
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
+ DtUnit.MICRO_SECONDS: [1.0, 2.0, 3.0, 4.0, 5.0, 10.0], # floats for microsec
}
def bestFormatString(spacing, unit):
- """ Finds the best format string given the spacing and DtUnit.
+ """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
@@ -310,8 +331,31 @@ def bestFormatString(spacing, unit):
raise ValueError("Unexpected DtUnit: {}".format(unit))
+def formatDatetimes(
+ datetimes: Sequence[dt.datetime], spacing: int | None, unit: DtUnit | None
+) -> dict[dt.datetime, str]:
+ """Returns formatted string for each datetime according to tick spacing and time unit"""
+ if spacing is None or unit is None:
+ # Locator has no spacing or units yet: Use elaborate fmtString
+ return {
+ datetime: datetime.strftime("Y-%m-%d %H:%M:%S") for datetime in datetimes
+ }
+
+ formatString = bestFormatString(spacing, unit)
+ if unit != DtUnit.MICRO_SECONDS:
+ return {datetime: datetime.strftime(formatString) for datetime in datetimes}
+
+ # For microseconds: Strip leading/trailing zeros
+ texts = tuple(datetime.strftime(formatString) for datetime in datetimes)
+ nzeros = min(len(text) - len(text.rstrip("0")) for text in texts)
+ return {
+ datetime: text[0 if text[0] != "0" else 1 : -min(nzeros, 5)]
+ for datetime, text in zip(datetimes, texts)
+ }
+
+
def niceDateTimeElement(value, unit, isRound=False):
- """ Uses the Nice Numbers algorithm to determine a nice value.
+ """Uses the Nice Numbers algorithm to determine a nice value.
The fractions are optimized for the unit of the date element.
"""
@@ -326,10 +370,8 @@ def niceDateTimeElement(value, unit, isRound=False):
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)
+ """Rounds a date down to the nearest nice number of ticks"""
+ assert dMax >= dMin, "dMin ({}) should come before dMax ({})".format(dMin, dMax)
if dMin == dMax:
# Fallback when range is smaller than microsecond resolution
@@ -337,34 +379,42 @@ def findStartDate(dMin, dMax, nTicks):
delta = dMax - dMin
lengthSec = delta.total_seconds()
- _logger.debug("findStartDate: {}, {} (duration = {} sec, {} days)"
- .format(dMin, dMax, lengthSec, lengthSec / SECONDS_PER_DAY))
+ _logger.debug(
+ "findStartDate: {}, {} (duration = {} sec, {} days)".format(
+ dMin, dMax, lengthSec, lengthSec / SECONDS_PER_DAY
+ )
+ )
length, unit = bestUnit(lengthSec)
niceLength = niceDateTimeElement(length, unit)
- _logger.debug("Length: {:8.3f} {} (nice = {})"
- .format(length, unit.name, niceLength))
+ _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))
+ _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
+ 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
+ niceVal = math.floor((dVal - 1) / niceSpacing) * niceSpacing + 1
else:
niceVal = math.floor(dVal / niceSpacing) * niceSpacing
if unit == DtUnit.YEARS and niceVal <= dt.MINYEAR:
niceVal = max(1, niceSpacing)
- _logger.debug("StartValue: dVal = {}, niceVal: {} ({})"
- .format(dVal, niceVal, unit.name))
+ _logger.debug(
+ "StartValue: dVal = {}, niceVal: {} ({})".format(dVal, niceVal, unit.name)
+ )
startDate = roundToElement(dMin, unit)
startDate = setDateElement(startDate, niceVal, unit)
@@ -372,8 +422,8 @@ def findStartDate(dMin, dMax, nTicks):
return startDate, niceSpacing, unit
-def dateRange(dMin, dMax, step, unit, includeFirstBeyond = False):
- """ Generates a range of dates
+def dateRange(dMin, dMax, step, unit, includeFirstBeyond=False):
+ """Generates a range of dates
:param datetime dMin: start date
:param datetime dMax: end date
@@ -384,8 +434,7 @@ def dateRange(dMin, dMax, step, unit, includeFirstBeyond = False):
datetime will always be smaller than dMax.
:return:
"""
- if (unit == DtUnit.YEARS or unit == DtUnit.MONTHS or
- unit == DtUnit.MICRO_SECONDS):
+ if unit == DtUnit.YEARS or unit == DtUnit.MONTHS or unit == DtUnit.MICRO_SECONDS:
# No support for fractional month or year and resolution is microsecond
# In those cases, make sure the step is at least 1
step = max(1, step)
@@ -404,7 +453,6 @@ def dateRange(dMin, dMax, step, unit, includeFirstBeyond = False):
yield dateTime
-
def calcTicks(dMin, dMax, nTicks):
"""Returns tick positions.
@@ -414,27 +462,19 @@ def calcTicks(dMin, dMax, nTicks):
ticks may differ.
:returns: (list of datetimes, DtUnit) tuple
"""
- _logger.debug("Calc calcTicks({}, {}, nTicks={})"
- .format(dMin, dMax, nTicks))
+ _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):
+ for d in dateRange(startDate, dMax, niceSpacing, unit, includeFirstBeyond=True):
result.append(d)
return result, niceSpacing, unit
def calcTicksAdaptive(dMin, dMax, axisLength, tickDensity):
- """ Calls calcTicks with a variable number of ticks, depending on axisLength
- """
+ """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)
-
-
-
-
-
+ return calcTicks(dMin, dMax, nticks)