1import contextlib 2import datetime as pydt 3from datetime import datetime, timedelta, tzinfo 4import functools 5from typing import Any, Dict, List, Optional, Tuple 6 7from dateutil.relativedelta import relativedelta 8import matplotlib.dates as dates 9from matplotlib.ticker import AutoLocator, Formatter, Locator 10from matplotlib.transforms import nonsingular 11import matplotlib.units as units 12import numpy as np 13 14from pandas._libs import lib 15from pandas._libs.tslibs import Timestamp, to_offset 16from pandas._libs.tslibs.dtypes import FreqGroup 17from pandas._libs.tslibs.offsets import BaseOffset 18 19from pandas.core.dtypes.common import ( 20 is_float, 21 is_float_dtype, 22 is_integer, 23 is_integer_dtype, 24 is_nested_list_like, 25) 26 27from pandas import Index, Series, get_option 28import pandas.core.common as com 29from pandas.core.indexes.datetimes import date_range 30from pandas.core.indexes.period import Period, PeriodIndex, period_range 31import pandas.core.tools.datetimes as tools 32 33# constants 34HOURS_PER_DAY = 24.0 35MIN_PER_HOUR = 60.0 36SEC_PER_MIN = 60.0 37 38SEC_PER_HOUR = SEC_PER_MIN * MIN_PER_HOUR 39SEC_PER_DAY = SEC_PER_HOUR * HOURS_PER_DAY 40 41MUSEC_PER_DAY = 1e6 * SEC_PER_DAY 42 43_mpl_units = {} # Cache for units overwritten by us 44 45 46def get_pairs(): 47 pairs = [ 48 (Timestamp, DatetimeConverter), 49 (Period, PeriodConverter), 50 (pydt.datetime, DatetimeConverter), 51 (pydt.date, DatetimeConverter), 52 (pydt.time, TimeConverter), 53 (np.datetime64, DatetimeConverter), 54 ] 55 return pairs 56 57 58def register_pandas_matplotlib_converters(func): 59 """ 60 Decorator applying pandas_converters. 61 """ 62 63 @functools.wraps(func) 64 def wrapper(*args, **kwargs): 65 with pandas_converters(): 66 return func(*args, **kwargs) 67 68 return wrapper 69 70 71@contextlib.contextmanager 72def pandas_converters(): 73 """ 74 Context manager registering pandas' converters for a plot. 75 76 See Also 77 -------- 78 register_pandas_matplotlib_converters : Decorator that applies this. 79 """ 80 value = get_option("plotting.matplotlib.register_converters") 81 82 if value: 83 # register for True or "auto" 84 register() 85 try: 86 yield 87 finally: 88 if value == "auto": 89 # only deregister for "auto" 90 deregister() 91 92 93def register(): 94 pairs = get_pairs() 95 for type_, cls in pairs: 96 # Cache previous converter if present 97 if type_ in units.registry and not isinstance(units.registry[type_], cls): 98 previous = units.registry[type_] 99 _mpl_units[type_] = previous 100 # Replace with pandas converter 101 units.registry[type_] = cls() 102 103 104def deregister(): 105 # Renamed in pandas.plotting.__init__ 106 for type_, cls in get_pairs(): 107 # We use type to catch our classes directly, no inheritance 108 if type(units.registry.get(type_)) is cls: 109 units.registry.pop(type_) 110 111 # restore the old keys 112 for unit, formatter in _mpl_units.items(): 113 if type(formatter) not in {DatetimeConverter, PeriodConverter, TimeConverter}: 114 # make it idempotent by excluding ours. 115 units.registry[unit] = formatter 116 117 118def _to_ordinalf(tm: pydt.time) -> float: 119 tot_sec = tm.hour * 3600 + tm.minute * 60 + tm.second + float(tm.microsecond / 1e6) 120 return tot_sec 121 122 123def time2num(d): 124 if isinstance(d, str): 125 parsed = tools.to_datetime(d) 126 if not isinstance(parsed, datetime): 127 raise ValueError(f"Could not parse time {d}") 128 return _to_ordinalf(parsed.time()) 129 if isinstance(d, pydt.time): 130 return _to_ordinalf(d) 131 return d 132 133 134class TimeConverter(units.ConversionInterface): 135 @staticmethod 136 def convert(value, unit, axis): 137 valid_types = (str, pydt.time) 138 if isinstance(value, valid_types) or is_integer(value) or is_float(value): 139 return time2num(value) 140 if isinstance(value, Index): 141 return value.map(time2num) 142 if isinstance(value, (list, tuple, np.ndarray, Index)): 143 return [time2num(x) for x in value] 144 return value 145 146 @staticmethod 147 def axisinfo(unit, axis) -> Optional[units.AxisInfo]: 148 if unit != "time": 149 return None 150 151 majloc = AutoLocator() 152 majfmt = TimeFormatter(majloc) 153 return units.AxisInfo(majloc=majloc, majfmt=majfmt, label="time") 154 155 @staticmethod 156 def default_units(x, axis) -> str: 157 return "time" 158 159 160# time formatter 161class TimeFormatter(Formatter): 162 def __init__(self, locs): 163 self.locs = locs 164 165 def __call__(self, x, pos=0) -> str: 166 """ 167 Return the time of day as a formatted string. 168 169 Parameters 170 ---------- 171 x : float 172 The time of day specified as seconds since 00:00 (midnight), 173 with up to microsecond precision. 174 pos 175 Unused 176 177 Returns 178 ------- 179 str 180 A string in HH:MM:SS.mmmuuu format. Microseconds, 181 milliseconds and seconds are only displayed if non-zero. 182 """ 183 fmt = "%H:%M:%S.%f" 184 s = int(x) 185 msus = int(round((x - s) * 1e6)) 186 ms = msus // 1000 187 us = msus % 1000 188 m, s = divmod(s, 60) 189 h, m = divmod(m, 60) 190 _, h = divmod(h, 24) 191 if us != 0: 192 return pydt.time(h, m, s, msus).strftime(fmt) 193 elif ms != 0: 194 return pydt.time(h, m, s, msus).strftime(fmt)[:-3] 195 elif s != 0: 196 return pydt.time(h, m, s).strftime("%H:%M:%S") 197 198 return pydt.time(h, m).strftime("%H:%M") 199 200 201# Period Conversion 202 203 204class PeriodConverter(dates.DateConverter): 205 @staticmethod 206 def convert(values, units, axis): 207 if is_nested_list_like(values): 208 values = [PeriodConverter._convert_1d(v, units, axis) for v in values] 209 else: 210 values = PeriodConverter._convert_1d(values, units, axis) 211 return values 212 213 @staticmethod 214 def _convert_1d(values, units, axis): 215 if not hasattr(axis, "freq"): 216 raise TypeError("Axis must have `freq` set to convert to Periods") 217 valid_types = (str, datetime, Period, pydt.date, pydt.time, np.datetime64) 218 if isinstance(values, valid_types) or is_integer(values) or is_float(values): 219 return get_datevalue(values, axis.freq) 220 elif isinstance(values, PeriodIndex): 221 return values.asfreq(axis.freq).asi8 222 elif isinstance(values, Index): 223 return values.map(lambda x: get_datevalue(x, axis.freq)) 224 elif lib.infer_dtype(values, skipna=False) == "period": 225 # https://github.com/pandas-dev/pandas/issues/24304 226 # convert ndarray[period] -> PeriodIndex 227 return PeriodIndex(values, freq=axis.freq).asi8 228 elif isinstance(values, (list, tuple, np.ndarray, Index)): 229 return [get_datevalue(x, axis.freq) for x in values] 230 return values 231 232 233def get_datevalue(date, freq): 234 if isinstance(date, Period): 235 return date.asfreq(freq).ordinal 236 elif isinstance(date, (str, datetime, pydt.date, pydt.time, np.datetime64)): 237 return Period(date, freq).ordinal 238 elif ( 239 is_integer(date) 240 or is_float(date) 241 or (isinstance(date, (np.ndarray, Index)) and (date.size == 1)) 242 ): 243 return date 244 elif date is None: 245 return None 246 raise ValueError(f"Unrecognizable date '{date}'") 247 248 249# Datetime Conversion 250class DatetimeConverter(dates.DateConverter): 251 @staticmethod 252 def convert(values, unit, axis): 253 # values might be a 1-d array, or a list-like of arrays. 254 if is_nested_list_like(values): 255 values = [DatetimeConverter._convert_1d(v, unit, axis) for v in values] 256 else: 257 values = DatetimeConverter._convert_1d(values, unit, axis) 258 return values 259 260 @staticmethod 261 def _convert_1d(values, unit, axis): 262 def try_parse(values): 263 try: 264 return dates.date2num(tools.to_datetime(values)) 265 except Exception: 266 return values 267 268 if isinstance(values, (datetime, pydt.date, np.datetime64, pydt.time)): 269 return dates.date2num(values) 270 elif is_integer(values) or is_float(values): 271 return values 272 elif isinstance(values, str): 273 return try_parse(values) 274 elif isinstance(values, (list, tuple, np.ndarray, Index, Series)): 275 if isinstance(values, Series): 276 # https://github.com/matplotlib/matplotlib/issues/11391 277 # Series was skipped. Convert to DatetimeIndex to get asi8 278 values = Index(values) 279 if isinstance(values, Index): 280 values = values.values 281 if not isinstance(values, np.ndarray): 282 values = com.asarray_tuplesafe(values) 283 284 if is_integer_dtype(values) or is_float_dtype(values): 285 return values 286 287 try: 288 values = tools.to_datetime(values) 289 except Exception: 290 pass 291 292 values = dates.date2num(values) 293 294 return values 295 296 @staticmethod 297 def axisinfo(unit: Optional[tzinfo], axis) -> units.AxisInfo: 298 """ 299 Return the :class:`~matplotlib.units.AxisInfo` for *unit*. 300 301 *unit* is a tzinfo instance or None. 302 The *axis* argument is required but not used. 303 """ 304 tz = unit 305 306 majloc = PandasAutoDateLocator(tz=tz) 307 majfmt = PandasAutoDateFormatter(majloc, tz=tz) 308 datemin = pydt.date(2000, 1, 1) 309 datemax = pydt.date(2010, 1, 1) 310 311 return units.AxisInfo( 312 majloc=majloc, majfmt=majfmt, label="", default_limits=(datemin, datemax) 313 ) 314 315 316class PandasAutoDateFormatter(dates.AutoDateFormatter): 317 def __init__(self, locator, tz=None, defaultfmt="%Y-%m-%d"): 318 dates.AutoDateFormatter.__init__(self, locator, tz, defaultfmt) 319 320 321class PandasAutoDateLocator(dates.AutoDateLocator): 322 def get_locator(self, dmin, dmax): 323 """Pick the best locator based on a distance.""" 324 delta = relativedelta(dmax, dmin) 325 326 num_days = (delta.years * 12.0 + delta.months) * 31.0 + delta.days 327 num_sec = (delta.hours * 60.0 + delta.minutes) * 60.0 + delta.seconds 328 tot_sec = num_days * 86400.0 + num_sec 329 330 if abs(tot_sec) < self.minticks: 331 self._freq = -1 332 locator = MilliSecondLocator(self.tz) 333 locator.set_axis(self.axis) 334 335 locator.set_view_interval(*self.axis.get_view_interval()) 336 locator.set_data_interval(*self.axis.get_data_interval()) 337 return locator 338 339 return dates.AutoDateLocator.get_locator(self, dmin, dmax) 340 341 def _get_unit(self): 342 return MilliSecondLocator.get_unit_generic(self._freq) 343 344 345class MilliSecondLocator(dates.DateLocator): 346 347 UNIT = 1.0 / (24 * 3600 * 1000) 348 349 def __init__(self, tz): 350 dates.DateLocator.__init__(self, tz) 351 self._interval = 1.0 352 353 def _get_unit(self): 354 return self.get_unit_generic(-1) 355 356 @staticmethod 357 def get_unit_generic(freq): 358 unit = dates.RRuleLocator.get_unit_generic(freq) 359 if unit < 0: 360 return MilliSecondLocator.UNIT 361 return unit 362 363 def __call__(self): 364 # if no data have been set, this will tank with a ValueError 365 try: 366 dmin, dmax = self.viewlim_to_dt() 367 except ValueError: 368 return [] 369 370 # We need to cap at the endpoints of valid datetime 371 nmax, nmin = dates.date2num((dmax, dmin)) 372 373 num = (nmax - nmin) * 86400 * 1000 374 max_millis_ticks = 6 375 for interval in [1, 10, 50, 100, 200, 500]: 376 if num <= interval * (max_millis_ticks - 1): 377 self._interval = interval 378 break 379 else: 380 # We went through the whole loop without breaking, default to 1 381 self._interval = 1000.0 382 383 estimate = (nmax - nmin) / (self._get_unit() * self._get_interval()) 384 385 if estimate > self.MAXTICKS * 2: 386 raise RuntimeError( 387 "MillisecondLocator estimated to generate " 388 f"{estimate:d} ticks from {dmin} to {dmax}: exceeds Locator.MAXTICKS" 389 f"* 2 ({self.MAXTICKS * 2:d}) " 390 ) 391 392 interval = self._get_interval() 393 freq = f"{interval}L" 394 tz = self.tz.tzname(None) 395 st = dmin.replace(tzinfo=None) 396 ed = dmin.replace(tzinfo=None) 397 all_dates = date_range(start=st, end=ed, freq=freq, tz=tz).astype(object) 398 399 try: 400 if len(all_dates) > 0: 401 locs = self.raise_if_exceeds(dates.date2num(all_dates)) 402 return locs 403 except Exception: # pragma: no cover 404 pass 405 406 lims = dates.date2num([dmin, dmax]) 407 return lims 408 409 def _get_interval(self): 410 return self._interval 411 412 def autoscale(self): 413 """ 414 Set the view limits to include the data range. 415 """ 416 # We need to cap at the endpoints of valid datetime 417 dmin, dmax = self.datalim_to_dt() 418 419 vmin = dates.date2num(dmin) 420 vmax = dates.date2num(dmax) 421 422 return self.nonsingular(vmin, vmax) 423 424 425def _from_ordinal(x, tz: Optional[tzinfo] = None) -> datetime: 426 ix = int(x) 427 dt = datetime.fromordinal(ix) 428 remainder = float(x) - ix 429 hour, remainder = divmod(24 * remainder, 1) 430 minute, remainder = divmod(60 * remainder, 1) 431 second, remainder = divmod(60 * remainder, 1) 432 microsecond = int(1e6 * remainder) 433 if microsecond < 10: 434 microsecond = 0 # compensate for rounding errors 435 dt = datetime( 436 dt.year, dt.month, dt.day, int(hour), int(minute), int(second), microsecond 437 ) 438 if tz is not None: 439 dt = dt.astimezone(tz) 440 441 if microsecond > 999990: # compensate for rounding errors 442 dt += timedelta(microseconds=1e6 - microsecond) 443 444 return dt 445 446 447# Fixed frequency dynamic tick locators and formatters 448 449# ------------------------------------------------------------------------- 450# --- Locators --- 451# ------------------------------------------------------------------------- 452 453 454def _get_default_annual_spacing(nyears) -> Tuple[int, int]: 455 """ 456 Returns a default spacing between consecutive ticks for annual data. 457 """ 458 if nyears < 11: 459 (min_spacing, maj_spacing) = (1, 1) 460 elif nyears < 20: 461 (min_spacing, maj_spacing) = (1, 2) 462 elif nyears < 50: 463 (min_spacing, maj_spacing) = (1, 5) 464 elif nyears < 100: 465 (min_spacing, maj_spacing) = (5, 10) 466 elif nyears < 200: 467 (min_spacing, maj_spacing) = (5, 25) 468 elif nyears < 600: 469 (min_spacing, maj_spacing) = (10, 50) 470 else: 471 factor = nyears // 1000 + 1 472 (min_spacing, maj_spacing) = (factor * 20, factor * 100) 473 return (min_spacing, maj_spacing) 474 475 476def period_break(dates: PeriodIndex, period: str) -> np.ndarray: 477 """ 478 Returns the indices where the given period changes. 479 480 Parameters 481 ---------- 482 dates : PeriodIndex 483 Array of intervals to monitor. 484 period : string 485 Name of the period to monitor. 486 """ 487 current = getattr(dates, period) 488 previous = getattr(dates - 1 * dates.freq, period) 489 return np.nonzero(current - previous)[0] 490 491 492def has_level_label(label_flags: np.ndarray, vmin: float) -> bool: 493 """ 494 Returns true if the ``label_flags`` indicate there is at least one label 495 for this level. 496 497 if the minimum view limit is not an exact integer, then the first tick 498 label won't be shown, so we must adjust for that. 499 """ 500 if label_flags.size == 0 or ( 501 label_flags.size == 1 and label_flags[0] == 0 and vmin % 1 > 0.0 502 ): 503 return False 504 else: 505 return True 506 507 508def _daily_finder(vmin, vmax, freq: BaseOffset): 509 dtype_code = freq._period_dtype_code 510 511 periodsperday = -1 512 513 if dtype_code >= FreqGroup.FR_HR: 514 if dtype_code == FreqGroup.FR_NS: 515 periodsperday = 24 * 60 * 60 * 1000000000 516 elif dtype_code == FreqGroup.FR_US: 517 periodsperday = 24 * 60 * 60 * 1000000 518 elif dtype_code == FreqGroup.FR_MS: 519 periodsperday = 24 * 60 * 60 * 1000 520 elif dtype_code == FreqGroup.FR_SEC: 521 periodsperday = 24 * 60 * 60 522 elif dtype_code == FreqGroup.FR_MIN: 523 periodsperday = 24 * 60 524 elif dtype_code == FreqGroup.FR_HR: 525 periodsperday = 24 526 else: # pragma: no cover 527 raise ValueError(f"unexpected frequency: {dtype_code}") 528 periodsperyear = 365 * periodsperday 529 periodspermonth = 28 * periodsperday 530 531 elif dtype_code == FreqGroup.FR_BUS: 532 periodsperyear = 261 533 periodspermonth = 19 534 elif dtype_code == FreqGroup.FR_DAY: 535 periodsperyear = 365 536 periodspermonth = 28 537 elif FreqGroup.get_freq_group(dtype_code) == FreqGroup.FR_WK: 538 periodsperyear = 52 539 periodspermonth = 3 540 else: # pragma: no cover 541 raise ValueError("unexpected frequency") 542 543 # save this for later usage 544 vmin_orig = vmin 545 546 (vmin, vmax) = ( 547 Period(ordinal=int(vmin), freq=freq), 548 Period(ordinal=int(vmax), freq=freq), 549 ) 550 span = vmax.ordinal - vmin.ordinal + 1 551 dates_ = period_range(start=vmin, end=vmax, freq=freq) 552 # Initialize the output 553 info = np.zeros( 554 span, dtype=[("val", np.int64), ("maj", bool), ("min", bool), ("fmt", "|S20")] 555 ) 556 info["val"][:] = dates_.asi8 557 info["fmt"][:] = "" 558 info["maj"][[0, -1]] = True 559 # .. and set some shortcuts 560 info_maj = info["maj"] 561 info_min = info["min"] 562 info_fmt = info["fmt"] 563 564 def first_label(label_flags): 565 if (label_flags[0] == 0) and (label_flags.size > 1) and ((vmin_orig % 1) > 0.0): 566 return label_flags[1] 567 else: 568 return label_flags[0] 569 570 # Case 1. Less than a month 571 if span <= periodspermonth: 572 day_start = period_break(dates_, "day") 573 month_start = period_break(dates_, "month") 574 575 def _hour_finder(label_interval, force_year_start): 576 _hour = dates_.hour 577 _prev_hour = (dates_ - 1 * dates_.freq).hour 578 hour_start = (_hour - _prev_hour) != 0 579 info_maj[day_start] = True 580 info_min[hour_start & (_hour % label_interval == 0)] = True 581 year_start = period_break(dates_, "year") 582 info_fmt[hour_start & (_hour % label_interval == 0)] = "%H:%M" 583 info_fmt[day_start] = "%H:%M\n%d-%b" 584 info_fmt[year_start] = "%H:%M\n%d-%b\n%Y" 585 if force_year_start and not has_level_label(year_start, vmin_orig): 586 info_fmt[first_label(day_start)] = "%H:%M\n%d-%b\n%Y" 587 588 def _minute_finder(label_interval): 589 hour_start = period_break(dates_, "hour") 590 _minute = dates_.minute 591 _prev_minute = (dates_ - 1 * dates_.freq).minute 592 minute_start = (_minute - _prev_minute) != 0 593 info_maj[hour_start] = True 594 info_min[minute_start & (_minute % label_interval == 0)] = True 595 year_start = period_break(dates_, "year") 596 info_fmt = info["fmt"] 597 info_fmt[minute_start & (_minute % label_interval == 0)] = "%H:%M" 598 info_fmt[day_start] = "%H:%M\n%d-%b" 599 info_fmt[year_start] = "%H:%M\n%d-%b\n%Y" 600 601 def _second_finder(label_interval): 602 minute_start = period_break(dates_, "minute") 603 _second = dates_.second 604 _prev_second = (dates_ - 1 * dates_.freq).second 605 second_start = (_second - _prev_second) != 0 606 info["maj"][minute_start] = True 607 info["min"][second_start & (_second % label_interval == 0)] = True 608 year_start = period_break(dates_, "year") 609 info_fmt = info["fmt"] 610 info_fmt[second_start & (_second % label_interval == 0)] = "%H:%M:%S" 611 info_fmt[day_start] = "%H:%M:%S\n%d-%b" 612 info_fmt[year_start] = "%H:%M:%S\n%d-%b\n%Y" 613 614 if span < periodsperday / 12000.0: 615 _second_finder(1) 616 elif span < periodsperday / 6000.0: 617 _second_finder(2) 618 elif span < periodsperday / 2400.0: 619 _second_finder(5) 620 elif span < periodsperday / 1200.0: 621 _second_finder(10) 622 elif span < periodsperday / 800.0: 623 _second_finder(15) 624 elif span < periodsperday / 400.0: 625 _second_finder(30) 626 elif span < periodsperday / 150.0: 627 _minute_finder(1) 628 elif span < periodsperday / 70.0: 629 _minute_finder(2) 630 elif span < periodsperday / 24.0: 631 _minute_finder(5) 632 elif span < periodsperday / 12.0: 633 _minute_finder(15) 634 elif span < periodsperday / 6.0: 635 _minute_finder(30) 636 elif span < periodsperday / 2.5: 637 _hour_finder(1, False) 638 elif span < periodsperday / 1.5: 639 _hour_finder(2, False) 640 elif span < periodsperday * 1.25: 641 _hour_finder(3, False) 642 elif span < periodsperday * 2.5: 643 _hour_finder(6, True) 644 elif span < periodsperday * 4: 645 _hour_finder(12, True) 646 else: 647 info_maj[month_start] = True 648 info_min[day_start] = True 649 year_start = period_break(dates_, "year") 650 info_fmt = info["fmt"] 651 info_fmt[day_start] = "%d" 652 info_fmt[month_start] = "%d\n%b" 653 info_fmt[year_start] = "%d\n%b\n%Y" 654 if not has_level_label(year_start, vmin_orig): 655 if not has_level_label(month_start, vmin_orig): 656 info_fmt[first_label(day_start)] = "%d\n%b\n%Y" 657 else: 658 info_fmt[first_label(month_start)] = "%d\n%b\n%Y" 659 660 # Case 2. Less than three months 661 elif span <= periodsperyear // 4: 662 month_start = period_break(dates_, "month") 663 info_maj[month_start] = True 664 if dtype_code < FreqGroup.FR_HR: 665 info["min"] = True 666 else: 667 day_start = period_break(dates_, "day") 668 info["min"][day_start] = True 669 week_start = period_break(dates_, "week") 670 year_start = period_break(dates_, "year") 671 info_fmt[week_start] = "%d" 672 info_fmt[month_start] = "\n\n%b" 673 info_fmt[year_start] = "\n\n%b\n%Y" 674 if not has_level_label(year_start, vmin_orig): 675 if not has_level_label(month_start, vmin_orig): 676 info_fmt[first_label(week_start)] = "\n\n%b\n%Y" 677 else: 678 info_fmt[first_label(month_start)] = "\n\n%b\n%Y" 679 # Case 3. Less than 14 months ............... 680 elif span <= 1.15 * periodsperyear: 681 year_start = period_break(dates_, "year") 682 month_start = period_break(dates_, "month") 683 week_start = period_break(dates_, "week") 684 info_maj[month_start] = True 685 info_min[week_start] = True 686 info_min[year_start] = False 687 info_min[month_start] = False 688 info_fmt[month_start] = "%b" 689 info_fmt[year_start] = "%b\n%Y" 690 if not has_level_label(year_start, vmin_orig): 691 info_fmt[first_label(month_start)] = "%b\n%Y" 692 # Case 4. Less than 2.5 years ............... 693 elif span <= 2.5 * periodsperyear: 694 year_start = period_break(dates_, "year") 695 quarter_start = period_break(dates_, "quarter") 696 month_start = period_break(dates_, "month") 697 info_maj[quarter_start] = True 698 info_min[month_start] = True 699 info_fmt[quarter_start] = "%b" 700 info_fmt[year_start] = "%b\n%Y" 701 # Case 4. Less than 4 years ................. 702 elif span <= 4 * periodsperyear: 703 year_start = period_break(dates_, "year") 704 month_start = period_break(dates_, "month") 705 info_maj[year_start] = True 706 info_min[month_start] = True 707 info_min[year_start] = False 708 709 month_break = dates_[month_start].month 710 jan_or_jul = month_start[(month_break == 1) | (month_break == 7)] 711 info_fmt[jan_or_jul] = "%b" 712 info_fmt[year_start] = "%b\n%Y" 713 # Case 5. Less than 11 years ................ 714 elif span <= 11 * periodsperyear: 715 year_start = period_break(dates_, "year") 716 quarter_start = period_break(dates_, "quarter") 717 info_maj[year_start] = True 718 info_min[quarter_start] = True 719 info_min[year_start] = False 720 info_fmt[year_start] = "%Y" 721 # Case 6. More than 12 years ................ 722 else: 723 year_start = period_break(dates_, "year") 724 year_break = dates_[year_start].year 725 nyears = span / periodsperyear 726 (min_anndef, maj_anndef) = _get_default_annual_spacing(nyears) 727 major_idx = year_start[(year_break % maj_anndef == 0)] 728 info_maj[major_idx] = True 729 minor_idx = year_start[(year_break % min_anndef == 0)] 730 info_min[minor_idx] = True 731 info_fmt[major_idx] = "%Y" 732 733 return info 734 735 736def _monthly_finder(vmin, vmax, freq): 737 periodsperyear = 12 738 739 vmin_orig = vmin 740 (vmin, vmax) = (int(vmin), int(vmax)) 741 span = vmax - vmin + 1 742 743 # Initialize the output 744 info = np.zeros( 745 span, dtype=[("val", int), ("maj", bool), ("min", bool), ("fmt", "|S8")] 746 ) 747 info["val"] = np.arange(vmin, vmax + 1) 748 dates_ = info["val"] 749 info["fmt"] = "" 750 year_start = (dates_ % 12 == 0).nonzero()[0] 751 info_maj = info["maj"] 752 info_fmt = info["fmt"] 753 754 if span <= 1.15 * periodsperyear: 755 info_maj[year_start] = True 756 info["min"] = True 757 758 info_fmt[:] = "%b" 759 info_fmt[year_start] = "%b\n%Y" 760 761 if not has_level_label(year_start, vmin_orig): 762 if dates_.size > 1: 763 idx = 1 764 else: 765 idx = 0 766 info_fmt[idx] = "%b\n%Y" 767 768 elif span <= 2.5 * periodsperyear: 769 quarter_start = (dates_ % 3 == 0).nonzero() 770 info_maj[year_start] = True 771 # TODO: Check the following : is it really info['fmt'] ? 772 info["fmt"][quarter_start] = True 773 info["min"] = True 774 775 info_fmt[quarter_start] = "%b" 776 info_fmt[year_start] = "%b\n%Y" 777 778 elif span <= 4 * periodsperyear: 779 info_maj[year_start] = True 780 info["min"] = True 781 782 jan_or_jul = (dates_ % 12 == 0) | (dates_ % 12 == 6) 783 info_fmt[jan_or_jul] = "%b" 784 info_fmt[year_start] = "%b\n%Y" 785 786 elif span <= 11 * periodsperyear: 787 quarter_start = (dates_ % 3 == 0).nonzero() 788 info_maj[year_start] = True 789 info["min"][quarter_start] = True 790 791 info_fmt[year_start] = "%Y" 792 793 else: 794 nyears = span / periodsperyear 795 (min_anndef, maj_anndef) = _get_default_annual_spacing(nyears) 796 years = dates_[year_start] // 12 + 1 797 major_idx = year_start[(years % maj_anndef == 0)] 798 info_maj[major_idx] = True 799 info["min"][year_start[(years % min_anndef == 0)]] = True 800 801 info_fmt[major_idx] = "%Y" 802 803 return info 804 805 806def _quarterly_finder(vmin, vmax, freq): 807 periodsperyear = 4 808 vmin_orig = vmin 809 (vmin, vmax) = (int(vmin), int(vmax)) 810 span = vmax - vmin + 1 811 812 info = np.zeros( 813 span, dtype=[("val", int), ("maj", bool), ("min", bool), ("fmt", "|S8")] 814 ) 815 info["val"] = np.arange(vmin, vmax + 1) 816 info["fmt"] = "" 817 dates_ = info["val"] 818 info_maj = info["maj"] 819 info_fmt = info["fmt"] 820 year_start = (dates_ % 4 == 0).nonzero()[0] 821 822 if span <= 3.5 * periodsperyear: 823 info_maj[year_start] = True 824 info["min"] = True 825 826 info_fmt[:] = "Q%q" 827 info_fmt[year_start] = "Q%q\n%F" 828 if not has_level_label(year_start, vmin_orig): 829 if dates_.size > 1: 830 idx = 1 831 else: 832 idx = 0 833 info_fmt[idx] = "Q%q\n%F" 834 835 elif span <= 11 * periodsperyear: 836 info_maj[year_start] = True 837 info["min"] = True 838 info_fmt[year_start] = "%F" 839 840 else: 841 years = dates_[year_start] // 4 + 1 842 nyears = span / periodsperyear 843 (min_anndef, maj_anndef) = _get_default_annual_spacing(nyears) 844 major_idx = year_start[(years % maj_anndef == 0)] 845 info_maj[major_idx] = True 846 info["min"][year_start[(years % min_anndef == 0)]] = True 847 info_fmt[major_idx] = "%F" 848 849 return info 850 851 852def _annual_finder(vmin, vmax, freq): 853 (vmin, vmax) = (int(vmin), int(vmax + 1)) 854 span = vmax - vmin + 1 855 856 info = np.zeros( 857 span, dtype=[("val", int), ("maj", bool), ("min", bool), ("fmt", "|S8")] 858 ) 859 info["val"] = np.arange(vmin, vmax + 1) 860 info["fmt"] = "" 861 dates_ = info["val"] 862 863 (min_anndef, maj_anndef) = _get_default_annual_spacing(span) 864 major_idx = dates_ % maj_anndef == 0 865 info["maj"][major_idx] = True 866 info["min"][(dates_ % min_anndef == 0)] = True 867 info["fmt"][major_idx] = "%Y" 868 869 return info 870 871 872def get_finder(freq: BaseOffset): 873 dtype_code = freq._period_dtype_code 874 fgroup = (dtype_code // 1000) * 1000 875 876 if fgroup == FreqGroup.FR_ANN: 877 return _annual_finder 878 elif fgroup == FreqGroup.FR_QTR: 879 return _quarterly_finder 880 elif dtype_code == FreqGroup.FR_MTH: 881 return _monthly_finder 882 elif (dtype_code >= FreqGroup.FR_BUS) or fgroup == FreqGroup.FR_WK: 883 return _daily_finder 884 else: # pragma: no cover 885 raise NotImplementedError(f"Unsupported frequency: {dtype_code}") 886 887 888class TimeSeries_DateLocator(Locator): 889 """ 890 Locates the ticks along an axis controlled by a :class:`Series`. 891 892 Parameters 893 ---------- 894 freq : {var} 895 Valid frequency specifier. 896 minor_locator : {False, True}, optional 897 Whether the locator is for minor ticks (True) or not. 898 dynamic_mode : {True, False}, optional 899 Whether the locator should work in dynamic mode. 900 base : {int}, optional 901 quarter : {int}, optional 902 month : {int}, optional 903 day : {int}, optional 904 """ 905 906 def __init__( 907 self, 908 freq, 909 minor_locator=False, 910 dynamic_mode=True, 911 base=1, 912 quarter=1, 913 month=1, 914 day=1, 915 plot_obj=None, 916 ): 917 freq = to_offset(freq) 918 self.freq = freq 919 self.base = base 920 (self.quarter, self.month, self.day) = (quarter, month, day) 921 self.isminor = minor_locator 922 self.isdynamic = dynamic_mode 923 self.offset = 0 924 self.plot_obj = plot_obj 925 self.finder = get_finder(freq) 926 927 def _get_default_locs(self, vmin, vmax): 928 """Returns the default locations of ticks.""" 929 if self.plot_obj.date_axis_info is None: 930 self.plot_obj.date_axis_info = self.finder(vmin, vmax, self.freq) 931 932 locator = self.plot_obj.date_axis_info 933 934 if self.isminor: 935 return np.compress(locator["min"], locator["val"]) 936 return np.compress(locator["maj"], locator["val"]) 937 938 def __call__(self): 939 """Return the locations of the ticks.""" 940 # axis calls Locator.set_axis inside set_m<xxxx>_formatter 941 942 vi = tuple(self.axis.get_view_interval()) 943 if vi != self.plot_obj.view_interval: 944 self.plot_obj.date_axis_info = None 945 self.plot_obj.view_interval = vi 946 vmin, vmax = vi 947 if vmax < vmin: 948 vmin, vmax = vmax, vmin 949 if self.isdynamic: 950 locs = self._get_default_locs(vmin, vmax) 951 else: # pragma: no cover 952 base = self.base 953 (d, m) = divmod(vmin, base) 954 vmin = (d + 1) * base 955 locs = list(range(vmin, vmax + 1, base)) 956 return locs 957 958 def autoscale(self): 959 """ 960 Sets the view limits to the nearest multiples of base that contain the 961 data. 962 """ 963 # requires matplotlib >= 0.98.0 964 (vmin, vmax) = self.axis.get_data_interval() 965 966 locs = self._get_default_locs(vmin, vmax) 967 (vmin, vmax) = locs[[0, -1]] 968 if vmin == vmax: 969 vmin -= 1 970 vmax += 1 971 return nonsingular(vmin, vmax) 972 973 974# ------------------------------------------------------------------------- 975# --- Formatter --- 976# ------------------------------------------------------------------------- 977 978 979class TimeSeries_DateFormatter(Formatter): 980 """ 981 Formats the ticks along an axis controlled by a :class:`PeriodIndex`. 982 983 Parameters 984 ---------- 985 freq : {int, string} 986 Valid frequency specifier. 987 minor_locator : bool, default False 988 Whether the current formatter should apply to minor ticks (True) or 989 major ticks (False). 990 dynamic_mode : bool, default True 991 Whether the formatter works in dynamic mode or not. 992 """ 993 994 def __init__( 995 self, 996 freq, 997 minor_locator: bool = False, 998 dynamic_mode: bool = True, 999 plot_obj=None, 1000 ): 1001 freq = to_offset(freq) 1002 self.format = None 1003 self.freq = freq 1004 self.locs: List[Any] = [] # unused, for matplotlib compat 1005 self.formatdict: Optional[Dict[Any, Any]] = None 1006 self.isminor = minor_locator 1007 self.isdynamic = dynamic_mode 1008 self.offset = 0 1009 self.plot_obj = plot_obj 1010 self.finder = get_finder(freq) 1011 1012 def _set_default_format(self, vmin, vmax): 1013 """Returns the default ticks spacing.""" 1014 if self.plot_obj.date_axis_info is None: 1015 self.plot_obj.date_axis_info = self.finder(vmin, vmax, self.freq) 1016 info = self.plot_obj.date_axis_info 1017 1018 if self.isminor: 1019 format = np.compress(info["min"] & np.logical_not(info["maj"]), info) 1020 else: 1021 format = np.compress(info["maj"], info) 1022 self.formatdict = {x: f for (x, _, _, f) in format} 1023 return self.formatdict 1024 1025 def set_locs(self, locs): 1026 """Sets the locations of the ticks""" 1027 # don't actually use the locs. This is just needed to work with 1028 # matplotlib. Force to use vmin, vmax 1029 1030 self.locs = locs 1031 1032 (vmin, vmax) = vi = tuple(self.axis.get_view_interval()) 1033 if vi != self.plot_obj.view_interval: 1034 self.plot_obj.date_axis_info = None 1035 self.plot_obj.view_interval = vi 1036 if vmax < vmin: 1037 (vmin, vmax) = (vmax, vmin) 1038 self._set_default_format(vmin, vmax) 1039 1040 def __call__(self, x, pos=0) -> str: 1041 1042 if self.formatdict is None: 1043 return "" 1044 else: 1045 fmt = self.formatdict.pop(x, "") 1046 if isinstance(fmt, np.bytes_): 1047 fmt = fmt.decode("utf-8") 1048 return Period(ordinal=int(x), freq=self.freq).strftime(fmt) 1049 1050 1051class TimeSeries_TimedeltaFormatter(Formatter): 1052 """ 1053 Formats the ticks along an axis controlled by a :class:`TimedeltaIndex`. 1054 """ 1055 1056 @staticmethod 1057 def format_timedelta_ticks(x, pos, n_decimals: int) -> str: 1058 """ 1059 Convert seconds to 'D days HH:MM:SS.F' 1060 """ 1061 s, ns = divmod(x, 1e9) 1062 m, s = divmod(s, 60) 1063 h, m = divmod(m, 60) 1064 d, h = divmod(h, 24) 1065 decimals = int(ns * 10 ** (n_decimals - 9)) 1066 s = f"{int(h):02d}:{int(m):02d}:{int(s):02d}" 1067 if n_decimals > 0: 1068 s += f".{decimals:0{n_decimals}d}" 1069 if d != 0: 1070 s = f"{int(d):d} days {s}" 1071 return s 1072 1073 def __call__(self, x, pos=0) -> str: 1074 (vmin, vmax) = tuple(self.axis.get_view_interval()) 1075 n_decimals = int(np.ceil(np.log10(100 * 1e9 / abs(vmax - vmin)))) 1076 if n_decimals > 9: 1077 n_decimals = 9 1078 return self.format_timedelta_ticks(x, pos, n_decimals) 1079