Skip to content

Scales

Axis scales: the data-space → transformed-space mapping that sits between raw data and the device transform. Pass a name or an instance to Axes.set(xscale=..., yscale=...). See the scales & ticks guide.

scales

Axis scales: the data-space -> transformed-space mapping that sits between raw data and the device transform.

A Scale owns three things an axis needs: a monotonic transform (and its inverse) used to position data, a tick locator+formatter (ticks), and an optional set of minor_ticks. The figure draws marks by composing the scale transform with an affine device map, so the Rust fast paths stay valid: they only ever see an affine map over transformed space (see Axes._draw/_draw_mark in pyplotrs._figure).

LinearScale is the default and is bit-for-bit identical to the previous linear-only behavior (its transform is the identity and its ticks defer to the Rust nice_ticks locator). Nonlinear scales (log, symlog, ...) override transform/inverse/ticks and set is_identity = False so the figure knows to pre-transform mark coordinates before the affine fast path.

Scale

Base axis scale. Subclasses override transform/inverse/ticks.

is_identity is a fast-path flag: when True the figure may skip the per-point transform pass entirely (the device map is already affine in data space), preserving the Rust polyline/marker fast paths. code names the transform for the Rust fast paths (add_line_xform/ add_markers_xform), which apply it per point in Rust — see apply_scale in crates/pyplotrs-py/src/lib.rs.

fixed_range

fixed_range() -> tuple[float, float] | None

A view range this scale pins regardless of the data, or None.

Only a categorical axis has one: its window is a property of the category list, not of the values plotted against it.

Source code in python/pyplotrs/scales.py
def fixed_range(self) -> tuple[float, float] | None:
    """A view range this scale pins regardless of the data, or ``None``.

    Only a categorical axis has one: its window is a property of the
    category list, not of the values plotted against it."""
    return None

clip_bounds

clip_bounds(
    bounds: tuple[float, float] | None, arrays: Sequence
) -> tuple[float, float] | None

Restrict raw (lo, hi) data bounds to this scale's domain.

Returns unpadded bounds in data space, or None when the data holds nothing this scale can show. arrays is every contributed coordinate array, for the domains that can't be decided from the bounds alone (a log axis needs the smallest positive value, which the overall minimum does not give it).

Source code in python/pyplotrs/scales.py
def clip_bounds(self, bounds: tuple[float, float] | None,
                arrays: Sequence) -> tuple[float, float] | None:
    """Restrict raw ``(lo, hi)`` data bounds to this scale's domain.

    Returns unpadded bounds in *data* space, or ``None`` when the data holds
    nothing this scale can show. ``arrays`` is every contributed coordinate
    array, for the domains that can't be decided from the bounds alone (a
    log axis needs the smallest *positive* value, which the overall minimum
    does not give it)."""
    return bounds

empty_range

empty_range() -> tuple[float, float]

The view to show when clip_bounds finds nothing representable.

Source code in python/pyplotrs/scales.py
def empty_range(self) -> tuple[float, float]:
    """The view to show when ``clip_bounds`` finds nothing representable."""
    return (0.0, 1.0)

ticks

ticks(lo: float, hi: float, max_ticks: int) -> list[Tick]

Major ticks as (value, label) pairs, value in data space.

Source code in python/pyplotrs/scales.py
def ticks(self, lo: float, hi: float, max_ticks: int) -> list[Tick]:
    """Major ticks as ``(value, label)`` pairs, value in *data* space."""
    raise NotImplementedError

minor_ticks

minor_ticks(lo: float, hi: float) -> list[float]

Minor-tick values (data space). Default: none.

Source code in python/pyplotrs/scales.py
def minor_ticks(self, lo: float, hi: float) -> list[float]:
    """Minor-tick *values* (data space). Default: none."""
    return []

LinearScale

Bases: Scale

The default linear scale: identity transform, nice_ticks locator.

LogScale

Bases: Scale

Base-10 logarithmic scale. Non-positive data is dropped (becomes a gap), matching matplotlib; the per-point transform runs in Rust.

SymlogScale

SymlogScale(linthresh: float = _SYMLOG_LINTHRESH)

Bases: Scale

Symmetric log: linear within [-linthresh, linthresh] and logarithmic beyond, so zero and negative values are representable.

linthresh is where the axis stops being linear. It has to match the data: the default of 1.0 is right for a signal measured in ones and wrong for one measured in microvolts, where it puts the entire dataset inside the linear region and produces a plain linear axis wearing a symlog label. Setting it is the whole point of the scale, so it is a constructor argument::

ax.set(yscale=pp.scales.SymlogScale(linthresh=1e-6))

The threshold travels to the Rust fast paths inside the scale code ("symlog:1e-06"), which is what lets the per-point transform stay in Rust while still being this axes' transform rather than a global constant.

Source code in python/pyplotrs/scales.py
def __init__(self, linthresh: float = _SYMLOG_LINTHRESH) -> None:
    linthresh = float(linthresh)
    if not (math.isfinite(linthresh) and linthresh > 0.0):
        raise ValueError(
            f"symlog linthresh must be finite and positive; got {linthresh!r}")
    self.linthresh = linthresh

ticks

ticks(
    lo: float, hi: float, max_ticks: int = 7
) -> list[Tick]

Decades outside the linear region, plus the threshold and zero.

The old version emitted decades and nothing else, so a view like [-3, 3] at the default threshold got ticks at only -1, 0 and 1 - every one of them inside the middle fifth of the axis, with the outer 80% carrying no label at all. The threshold itself was never marked either, so nothing on the axis told a reader where the scale stops being logarithmic. Both are fixed here: ±linthresh is always a tick when it is in view, and an axis too narrow to hold two decades falls back to plain nice numbers, which is what such an axis actually is.

Source code in python/pyplotrs/scales.py
def ticks(self, lo: float, hi: float, max_ticks: int = 7) -> list[Tick]:
    """Decades outside the linear region, plus the threshold and zero.

    The old version emitted decades and nothing else, so a view like
    ``[-3, 3]`` at the default threshold got ticks at only -1, 0 and 1 -
    every one of them inside the middle fifth of the axis, with the outer
    80% carrying no label at all. The threshold itself was never marked
    either, so nothing on the axis told a reader where the scale stops
    being logarithmic. Both are fixed here: `±linthresh` is always a tick
    when it is in view, and an axis too narrow to hold two decades falls
    back to plain nice numbers, which is what such an axis actually is.
    """
    vals = set(self._decades(lo, hi))
    if len(vals) < 2:
        return nice_ticks(lo, hi, max_ticks)
    # Too few decades in view to carry the axis on their own: subdivide
    # them, widening the subdivision until there are enough ticks to read
    # by. `[-3, 3]` at a threshold of 1 has exactly two decades, both
    # inside the middle fifth - without this the outer 80% of the axis
    # holds no tick at all.
    for subs in ((2, 5), (2, 3, 4, 5, 6, 7, 8, 9)):
        if len(vals) >= max_ticks - 1:
            break
        for sign in (1.0, -1.0):
            for k in range(-300, 301):
                base = 10.0 ** k
                if base < self.linthresh:
                    continue
                for mult in subs:
                    v = sign * mult * base
                    if lo <= v <= hi:
                        vals.add(v)
    if lo <= 0.0 <= hi:
        vals.add(0.0)
    # Mark the linear/log crossover, on whichever side is in view.
    for edge in (self.linthresh, -self.linthresh):
        if lo <= edge <= hi:
            vals.add(edge)
    return [(v, _fmt_symlog(v)) for v in sorted(vals)]

minor_ticks

minor_ticks(lo: float, hi: float) -> list[float]

The 2..9 subdivisions of each decade, as on a log axis.

A symlog axis had none at all, so between two labeled decades there was no way to read off where a point sat - the one thing minor ticks are for on a logarithmic axis.

Source code in python/pyplotrs/scales.py
def minor_ticks(self, lo: float, hi: float) -> list[float]:
    """The 2..9 subdivisions of each decade, as on a log axis.

    A symlog axis had none at all, so between two labeled decades there was
    no way to read off where a point sat - the one thing minor ticks are
    for on a logarithmic axis.
    """
    out: list[float] = []
    for sign in (1.0, -1.0):
        for k in range(-300, 301):
            base = 10.0 ** k
            if base < self.linthresh:
                continue
            for mult in range(2, 10):
                v = sign * mult * base
                if abs(v) >= self.linthresh and lo <= v <= hi:
                    out.append(v)
    return out

LogitScale

Bases: Scale

Logit scale for probabilities in (0, 1): log10(p / (1 - p)).

CategoricalScale

CategoricalScale(categories: Sequence)

Bases: Scale

A discrete axis: string categories occupy integer positions 0..n-1.

Data are mapped to their category index before plotting (see Axes._categorize); the transform is the identity in that index space, so the Rust affine fast paths are untouched (code = "linear"). Ticks are one per category, centered on its position; the view spans -0.5 .. n-0.5.

Source code in python/pyplotrs/scales.py
def __init__(self, categories: Sequence) -> None:
    self.categories = [str(c) for c in categories]
    self.index = {c: i for i, c in enumerate(self.categories)}

DateScale

Bases: Scale

A time axis over float day numbers (date2num, days since 1970-01-01). Datetime inputs are converted on the way in; ticks fall on calendar boundaries (year/month/day/hour) chosen from the visible span.

get

get(scale) -> Scale

Resolve scale (a Scale, a name string, or None) to a concrete Scale. None/"linear" -> LinearScale.

Source code in python/pyplotrs/scales.py
def get(scale) -> Scale:
    """Resolve ``scale`` (a ``Scale``, a name string, or ``None``) to a
    concrete ``Scale``. ``None``/``"linear"`` -> ``LinearScale``."""
    if scale is None:
        return LinearScale()
    if isinstance(scale, Scale):
        return scale
    if isinstance(scale, str):
        try:
            return _BY_NAME[scale]()
        except KeyError:
            raise ValueError(
                f"unknown scale {scale!r}; expected one of {sorted(_BY_NAME)}"
            )
    raise TypeError(f"expected a Scale, scale name, or None; got {type(scale).__name__}")

nice_ticks

nice_ticks(
    lo: float, hi: float, max_ticks: int
) -> list[Tick]

The Rust "nice numbers" auto-locator, signed for display.

Every caller of the locator goes through here rather than _core.nice_ticks: the Rust side formats with an ASCII hyphen (it is a pure function with no view of the display setting) and this is where that becomes a real MINUS. Doing it before the labels reach the layout engine keeps the pre-measured extents honest - a minus is nearly twice the width of a hyphen.

Source code in python/pyplotrs/scales.py
def nice_ticks(lo: float, hi: float, max_ticks: int) -> list[Tick]:
    """The Rust "nice numbers" auto-locator, signed for display.

    Every caller of the locator goes through here rather than
    ``_core.nice_ticks``: the Rust side formats with an ASCII hyphen (it is a
    pure function with no view of the display setting) and this is where that
    becomes a real [`MINUS`][pyplotrs.ticker.MINUS]. Doing it before the labels
    reach the layout engine keeps the pre-measured extents honest - a minus is
    nearly twice the width of a hyphen.
    """
    return [(v, fix_minus(s)) for v, s in _core.nice_ticks(lo, hi, max_ticks)]

date2num

date2num(v) -> float

Convert a datetime-like value to float days since 1970-01-01.

Source code in python/pyplotrs/scales.py
def date2num(v) -> float:
    """Convert a datetime-like value to float **days since 1970-01-01**."""
    if isinstance(v, _dt.datetime):
        return (v - _EPOCH).total_seconds() / 86400.0
    if isinstance(v, _dt.date):
        return (_dt.datetime(v.year, v.month, v.day) - _EPOCH).total_seconds() / 86400.0
    if hasattr(v, "to_pydatetime"):  # pandas Timestamp
        return date2num(v.to_pydatetime())
    if type(v).__name__ == "datetime64":  # numpy datetime64
        import numpy as _np  # local: numpy is optional
        ns = _np.datetime64(v, "ns").astype("int64")
        return ns / 1e9 / 86400.0
    return float(v)

num2date

num2date(x: float) -> _dt.datetime

Inverse of date2num: a float day-number back to a datetime.

Source code in python/pyplotrs/scales.py
def num2date(x: float) -> _dt.datetime:
    """Inverse of ``date2num``: a float day-number back to a ``datetime``."""
    return _EPOCH + _dt.timedelta(days=float(x))

is_datetime_like

is_datetime_like(v) -> bool

Whether v is a datetime we can place on a DateScale (datetime/date, pandas Timestamp, or NumPy datetime64).

Source code in python/pyplotrs/scales.py
def is_datetime_like(v) -> bool:
    """Whether ``v`` is a datetime we can place on a ``DateScale``
    (``datetime``/``date``, pandas ``Timestamp``, or NumPy ``datetime64``)."""
    if isinstance(v, (_dt.datetime, _dt.date)):
        return True
    if hasattr(v, "to_pydatetime"):  # pandas Timestamp
        return True
    return type(v).__name__ == "datetime64"  # numpy