Source code for faninsar.plots.formatters
"""Matplotlib formatters and locators for phase visualization."""
from __future__ import annotations
from fractions import Fraction
from typing import TYPE_CHECKING
import numpy as np
from matplotlib.ticker import Formatter, Locator, MultipleLocator
if TYPE_CHECKING:
from matplotlib.axis import Axis
from numpy.typing import NDArray
[docs]
class PiFormatter(Formatter):
"""Format axis tick labels as multiples of π.
This formatter displays values in terms of π, making it easier to read
phase values that are naturally expressed in radians.
"""
[docs]
def __init__(
self,
denominator: int = 4,
symbol: str | None = None,
use_unicode: bool = True,
latex: bool = False,
) -> None:
r"""Initialize the formatter.
Parameters
----------
denominator : int, optional
The maximum denominator for fraction simplification. Default is 4.
symbol : str, optional
The symbol to use for pi. Default is 'π'. Can also use 'pi'. When
``latex`` is True, the default symbol becomes ``"\\pi"``.
use_unicode : bool, optional
Whether to use Unicode π symbol (default) or 'pi' string. Ignored
when ``latex`` is True and ``symbol`` is not provided.
latex : bool, optional
Whether to return labels wrapped in mathtext (LaTeX) for improved
rendering. Default is False.
Examples
--------
>>> import matplotlib.pyplot as plt
>>> from faninsar.plots import PiFormatter, PiLocator
>>> fig, ax = plt.subplots()
>>> ax.xaxis.set_major_formatter(PiFormatter())
>>> ax.xaxis.set_major_locator(PiLocator())
"""
super().__init__()
self.denominator = denominator
self.use_unicode = use_unicode
self.latex = latex
if symbol is not None:
self.symbol = symbol
elif latex:
self.symbol = r"\pi"
else:
self.symbol = "π" if use_unicode else "pi"
def __call__(self, x: float, pos: int | None = None) -> str: # noqa: ARG002
"""Format a tick value as a multiple of π.
Parameters
----------
x : float
The tick value in radians.
pos : int, optional
The tick position (not used).
Returns
-------
str
Formatted tick label.
"""
if x == 0:
return "$0$" if self.latex else "0"
# Convert to multiples of pi
x_pi = x / np.pi
# Handle very small values
if abs(x_pi) < 1e-10:
return "$0$" if self.latex else "0"
# Try to express as a fraction
try:
frac = Fraction(x_pi).limit_denominator(self.denominator)
numerator = frac.numerator
denominator = frac.denominator
if abs(float(frac) - x_pi) > 1e-6:
return self._format_decimal(x_pi)
if self.latex:
return self._format_latex(numerator, denominator)
return self._format_plain(numerator, denominator)
except (ValueError, ZeroDivisionError):
# Fallback to decimal representation
return self._format_decimal(x_pi)
def _format_plain(self, numerator: int, denominator: int) -> str:
"""Format numerator/denominator as a plain-text multiple of π."""
if denominator == 1:
if numerator == 1:
return f"{self.symbol}"
if numerator == -1:
return f"-{self.symbol}"
return f"{numerator}{self.symbol}"
if numerator == 1:
return f"{self.symbol}/{denominator}"
if numerator == -1:
return f"-{self.symbol}/{denominator}"
return f"{numerator}{self.symbol}/{denominator}"
def _format_latex(self, numerator: int, denominator: int) -> str:
"""Format numerator/denominator as a LaTeX multiple of π."""
sign = "-" if numerator < 0 else ""
abs_numerator = abs(numerator)
if denominator == 1:
if abs_numerator == 1:
return rf"${sign}{self.symbol}$"
return rf"${sign}{abs_numerator}{self.symbol}$"
if abs_numerator == 1:
return rf"${sign}\frac{{{self.symbol}}}{{{denominator}}}$"
return rf"${sign}\frac{{{abs_numerator}{self.symbol}}}{{{denominator}}}$"
def _format_decimal(self, value: float) -> str:
"""Format a decimal multiple of π."""
if self.latex:
sign = "-" if value < 0 else ""
magnitude = abs(value)
return rf"${sign}{magnitude:.2g}{self.symbol}$"
return f"{value:.2g}{self.symbol}"
[docs]
class PiLocator(Locator):
"""Locate tick positions at multiples of π.
This locator places ticks at nice intervals based on π, making it easier
to read phase plots.
"""
[docs]
def __init__(self, base: float = 0.5) -> None:
"""Initialize the locator.
Parameters
----------
base : float, optional
The base multiple of π for tick spacing. Default is 0.5 (π/2).
Common values are 1.0 (π), 0.5 (π/2), 0.25 (π/4).
Examples
--------
>>> import matplotlib.pyplot as plt
>>> from faninsar.plots import PiFormatter, PiLocator
>>> fig, ax = plt.subplots()
>>> ax.xaxis.set_major_locator(PiLocator(base=0.5)) # Ticks at multiples of π/2
>>> ax.xaxis.set_major_formatter(PiFormatter())
"""
super().__init__()
self.base = base
self._locator = MultipleLocator(base * np.pi)
def __call__(self) -> NDArray[np.floating]:
"""Return the locations of the ticks."""
return self.tick_values(*self.axis.get_view_interval())
[docs]
def tick_values(self, vmin: float, vmax: float) -> NDArray[np.floating]:
"""Return tick values within the given range.
Parameters
----------
vmin : float
Minimum value of the axis range.
vmax : float
Maximum value of the axis range.
Returns
-------
array
Array of tick locations.
"""
return self._locator.tick_values(vmin, vmax)
[docs]
def view_limits(self, dmin: float, dmax: float) -> tuple[float, float]:
"""Set the view limits to nice values around the data range.
Parameters
----------
dmin : float
Minimum data value.
dmax : float
Maximum data value.
Returns
-------
tuple
Nice view limits (vmin, vmax).
"""
return self._locator.view_limits(dmin, dmax)
[docs]
def setup_phase_axis(
axis: Axis,
base: float = 0.5,
denominator: int = 4,
use_unicode: bool = True,
latex: bool = False,
) -> None:
"""Configure an axis for phase display with π-based ticks.
This is a convenience function to set up both the formatter and locator
for a phase axis in one call.
Parameters
----------
axis : matplotlib.axis.Axis
The axis to configure (e.g., ax.xaxis or ax.yaxis).
base : float, optional
The base multiple of π for tick spacing. Default is 0.5 (π/2).
denominator : int, optional
The maximum denominator for fraction simplification. Default is 4.
use_unicode : bool, optional
Whether to use Unicode π symbol. Default is True.
latex : bool, optional
Whether to format labels using LaTeX. Default is False.
Examples
--------
>>> import matplotlib.pyplot as plt
>>> from faninsar.plots import setup_phase_axis
>>> fig, ax = plt.subplots()
>>> setup_phase_axis(ax.xaxis, base=0.5)
>>> ax.plot(
... np.linspace(-np.pi, np.pi, 100), np.sin(np.linspace(-np.pi, np.pi, 100))
... )
"""
axis.set_major_locator(PiLocator(base=base))
axis.set_major_formatter(
PiFormatter(
denominator=denominator,
use_unicode=use_unicode,
latex=latex,
)
)