Source code for ggstyle._cadence

"""Tick cadence: the spec object, named vocabulary, and the auto table.

A :class:`Cadence` is *where ticks go*. It says nothing about what the labels
look like — that is ``_formats``. Keeping these apart is the point: changing a
format must never move a tick.
"""

from __future__ import annotations

from collections.abc import Iterable
from dataclasses import dataclass

import pandas as pd

from ._parse import to_offset

__all__ = ["LADDER", "Cadence", "auto_cadence", "resolve"]

_UNITS = ("minute", "hour", "day", "week", "month", "quarter", "year")

#: Approximate length of one unit, for span arithmetic and ladder search.
_UNIT_SECONDS = {
    "minute": 60.0,
    "hour": 3600.0,
    "day": 86400.0,
    "week": 604800.0,
    "month": 2629800.0,
    "quarter": 7889400.0,
    "year": 31557600.0,
}

#: Offset alias for each unit at each anchor.
_ALIAS = {
    ("minute", "start"): "min",
    ("minute", "end"): "min",
    ("hour", "start"): "h",
    ("hour", "end"): "h",
    ("day", "start"): "D",
    ("day", "end"): "D",
    ("week", "start"): "W-MON",
    ("week", "end"): "W-SUN",
    ("month", "start"): "MS",
    ("month", "end"): "ME",
    ("quarter", "start"): "QS",
    ("quarter", "end"): "QE",
    ("year", "start"): "YS",
    ("year", "end"): "YE",
}

#: Period alias for grouping observations, used in collapsed mode.
_PERIOD_ALIAS = {
    "minute": "min",
    "hour": "h",
    "day": "D",
    "week": "W",
    "month": "M",
    "quarter": "Q",
    "year": "Y",
}


[docs] @dataclass(frozen=True) class Cadence: """ Describe a recurring tick cadence. A cadence controls tick placement only. Label text is configured separately by :meth:`ggstyle.DateAxis.fmt`. Parameters ---------- unit : {"minute", "hour", "day", "week", "month", "quarter", "year"} One of minute, hour, day, week, month, quarter, year. interval : int, default 1 Every *n*-th unit. anchor : {"start", "end"}, default "start" ``"start"`` or ``"end"`` of each period. Month-start vs. month-end is the difference between labels that line up with observations and labels that float between them. Raises ------ ValueError If the unit, interval, or anchor is invalid. See Also -------- ggstyle.DateAxis.ticks : Apply a cadence to an axis. Examples -------- >>> Cadence("month", interval=3) Cadence(unit='month', interval=3, anchor='start') """ unit: str interval: int = 1 anchor: str = "start" def __post_init__(self) -> None: if self.unit not in _UNITS: raise ValueError( f"unknown cadence unit {self.unit!r}; expected one of {_UNITS}" ) if self.interval < 1: raise ValueError(f"interval must be >= 1, got {self.interval}") if self.anchor not in ("start", "end"): raise ValueError(f"anchor must be 'start' or 'end', got {self.anchor!r}") @property def freq(self) -> str: """Return the pandas offset alias used to generate ticks.""" alias = _ALIAS[(self.unit, self.anchor)] return alias if self.interval == 1 else f"{self.interval}{alias}" @property def period_alias(self) -> str: """Return the pandas period alias used to group observations.""" return _PERIOD_ALIAS[self.unit] @property def approx_seconds(self) -> float: """Return the approximate cadence duration in seconds.""" return _UNIT_SECONDS[self.unit] * self.interval def __str__(self) -> str: # pragma: no cover - display only every = "" if self.interval == 1 else f"{self.interval}x " return f"{every}{self.unit}[{self.anchor}]"
#: Named vocabulary. The bare names are the ones people reach for; the anchored #: names exist because financial data cares which end of the period it lands on. _NAMED = { "minutely": Cadence("minute"), "hourly": Cadence("hour"), "daily": Cadence("day"), "weekly": Cadence("week"), "monthly": Cadence("month"), "quarterly": Cadence("quarter"), "yearly": Cadence("year"), "annual": Cadence("year"), "annually": Cadence("year"), "week-start": Cadence("week", anchor="start"), "week-end": Cadence("week", anchor="end"), "month-start": Cadence("month", anchor="start"), "month-end": Cadence("month", anchor="end"), "quarter-start": Cadence("quarter", anchor="start"), "quarter-end": Cadence("quarter", anchor="end"), "year-start": Cadence("year", anchor="start"), "year-end": Cadence("year", anchor="end"), } #: Candidate cadences in increasing coarseness, searched when the caller asks #: for "about n ticks". LADDER: tuple[Cadence, ...] = ( Cadence("minute", 1), Cadence("minute", 5), Cadence("minute", 15), Cadence("minute", 30), Cadence("hour", 1), Cadence("hour", 3), Cadence("hour", 6), Cadence("hour", 12), Cadence("day", 1), Cadence("day", 2), Cadence("week", 1), Cadence("week", 2), Cadence("month", 1), Cadence("month", 2), Cadence("month", 3), Cadence("month", 6), Cadence("year", 1), Cadence("year", 2), Cadence("year", 5), Cadence("year", 10), Cadence("year", 25), ) def resolve(spec) -> Cadence: """Turn a user-facing cadence spec into a :class:`Cadence`. Accepts a :class:`Cadence`, a named string (``"quarterly"``, ``"month-end"``), or an offset alias (``"3M"``, ``"6ME"``, ``"2W"``). """ if isinstance(spec, Cadence): return spec if not isinstance(spec, str): raise TypeError(f"cannot interpret {spec!r} as a cadence") key = spec.strip().lower() if key in _NAMED: return _NAMED[key] return _from_alias(spec.strip()) def _from_alias(alias: str) -> Cadence: """Interpret an offset alias such as ``"3M"`` or ``"6ME"`` as a Cadence.""" offset = to_offset(alias) interval = int(getattr(offset, "n", 1)) or 1 name = type(offset).__name__ mapping = { "Minute": ("minute", "start"), "Hour": ("hour", "start"), "Day": ("day", "start"), "Week": ("week", "start"), "MonthBegin": ("month", "start"), "MonthEnd": ("month", "end"), "QuarterBegin": ("quarter", "start"), "QuarterEnd": ("quarter", "end"), "YearBegin": ("year", "start"), "YearEnd": ("year", "end"), "BusinessDay": ("day", "start"), } if name not in mapping: raise ValueError( f"offset {alias!r} has no cadence equivalent; pass a named " f"cadence such as 'monthly', or an explicit tick list via at=[...]" ) unit, anchor = mapping[name] return Cadence(unit, interval, anchor) def auto_cadence(span: pd.Timedelta) -> tuple[Cadence, Cadence, str]: """Pick major cadence, minor cadence, and label preset for a visible span. This is the zero-config path and the spec that §6.7 of the plan describes. Thresholds are deliberately blunt round numbers so the behaviour is easy to predict and easy to test. """ days = max(float(pd.Timedelta(span) / pd.Timedelta(days=1)), 0.0) if days < 1: return Cadence("hour"), Cadence("minute", 15), "time" if days <= 7: return Cadence("day"), Cadence("hour", 6), "weekday" if days <= 92: return Cadence("week"), Cadence("day"), "day" if days <= 548: return Cadence("month"), Cadence("week"), "month" if days <= 1826: return Cadence("quarter"), Cadence("month"), "quarter-short" if days <= 5479: return Cadence("year"), Cadence("quarter"), "year" return Cadence("year", 5), Cadence("year"), "year" def best_for_count(span: pd.Timedelta, n: int) -> Cadence: """Coarsest-to-finest search for the cadence yielding closest to ``n`` ticks.""" if n < 1: raise ValueError(f"n must be >= 1, got {n}") seconds = max(float(pd.Timedelta(span).total_seconds()), 1.0) best: Cadence | None = None best_error = float("inf") for cadence in LADDER: count = seconds / cadence.approx_seconds error = abs(count - n) if error < best_error: best, best_error = cadence, error assert best is not None return best #: Floor the generated range's start before building candidates. pandas keeps the #: time-of-day of the start value even for anchored offsets, so a padded start of #: 13:30 yields "month starts" at 13:30 on the 1st -- which then sort after the #: midnight observation they are meant to mark. Sub-day cadences floor to their #: own unit; everything calendar-based floors to midnight. _FLOOR_ALIAS = { "minute": "min", "hour": "h", "day": "D", "week": "D", "month": "D", "quarter": "D", "year": "D", } def periods_between( cadence: Cadence, lo: pd.Timestamp, hi: pd.Timestamp ) -> pd.DatetimeIndex: """Candidate tick timestamps for ``cadence`` covering ``[lo, hi]``. Generated one cadence step wide on each side so that the caller can snap to nearby observations without losing edge ticks. """ pad = pd.Timedelta(seconds=cadence.approx_seconds) start = lo - pad floor = _FLOOR_ALIAS.get(cadence.unit) if floor is not None: start = start.floor(floor) return pd.date_range(start=start, end=hi + pad, freq=to_offset(cadence.freq)) def _iter_units() -> Iterable[str]: # pragma: no cover - introspection helper return _UNITS