Coverage for src / cosmic_toolbox / styles / base.py: 100%
43 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-31 12:38 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-31 12:38 +0000
1# Copyright (C) 2026 ETH Zurich
2# Institute for Particle Physics and Astrophysics
3# Author: Silvan Fischbacher
4# created: Thu Mar 26 2026
7import shutil
8import warnings
10import matplotlib as mpl
11import matplotlib.pyplot as plt
13from cosmic_toolbox.colors import get_colors
16def _latex_available() -> bool:
17 """Check if LaTeX is available on the system."""
18 return shutil.which("latex") is not None
21class PaperStyle:
22 # ── Override these in subclasses ──────────────────────────────────────
23 TEXTWIDTH_PT: float = 510.0 # \the\textwidth in your document
24 TEXTHEIGHT_PT: float | None = None # optional; for full-page figures
25 FONT_SIZE: int = 10 # body text font size
26 LATEX_PREAMBLE: str = (
27 r"\usepackage{amsmath}" r"\usepackage{amssymb}" r"\usepackage{bm}"
28 )
30 # ── Derived geometry (do not override) ───────────────────────────────
31 PT_PER_INCH = 72.27
32 GOLDEN = (1 + 5**0.5) / 2
34 @classmethod
35 def textwidth(cls) -> float:
36 """
37 Full textwidth in inches.
38 """
39 return cls.TEXTWIDTH_PT / cls.PT_PER_INCH
41 @classmethod
42 def textheight(cls) -> float | None:
43 """
44 Full textheight in inches, or None if not set.
45 """
46 if cls.TEXTHEIGHT_PT is None:
47 return None
48 return cls.TEXTHEIGHT_PT / cls.PT_PER_INCH
50 @classmethod
51 def fig_size(cls, width: float | str = 1.0, aspect: float | None = None):
52 """
53 Return (width_in, height_in) for a figure.
55 :param width: fraction of textwidth as float, 0.5 = half, 1.0 = full
56 or inches as a string "3.5in"
57 :param aspect: height / width, default 1/golden ≈ 0.618
58 :return: (width_in, height_in)
59 """
60 if isinstance(width, str) and width.endswith("in"):
61 w = float(width[:-2])
62 else:
63 w = cls.textwidth() * float(width)
65 h = w / cls.GOLDEN if aspect is None else w * aspect
66 return (w, h)
68 @classmethod
69 def set_mpl(cls, use_latex: bool | None = None):
70 """
71 Apply this journal's rcParams globally.
73 :param use_latex: whether to use LaTeX for text rendering. If None (default),
74 will auto-detect if LaTeX is available on the system and fall back to
75 mathtext if not. Note that LaTeX must be installed for publication-quality
76 figures, so it's recommended to install it and let the function auto-detect
77 it.
78 """
79 fs = cls.FONT_SIZE
80 if use_latex is None:
81 use_latex = _latex_available()
82 if not use_latex:
83 warnings.warn(
84 "LaTeX not found on system. Falling back to mathtext rendering. "
85 "Install LaTeX for publication-quality figures.",
86 UserWarning,
87 stacklevel=2,
88 )
89 mpl.rcParams.update(
90 {
91 # ── Figure ───────────────────────────────────────────────
92 "figure.figsize": cls.fig_size(1.0),
93 "figure.dpi": 150,
94 "savefig.dpi": 600,
95 "savefig.bbox": "tight",
96 "savefig.pad_inches": 0.02,
97 "figure.constrained_layout.use": True,
98 "savefig.format": "pdf",
99 # ── Font ─────────────────────────────────────────────────
100 "text.usetex": use_latex,
101 "text.latex.preamble": cls.LATEX_PREAMBLE,
102 "font.family": "serif",
103 "font.size": fs,
104 # ── Axes ─────────────────────────────────────────────────
105 "axes.labelsize": fs,
106 "axes.titlesize": fs,
107 "axes.linewidth": 0.8,
108 "axes.labelpad": 4.0,
109 "axes.formatter.use_mathtext": True,
110 "axes.formatter.limits": (-3, 4),
111 "axes.grid": False,
112 # ── Ticks ────────────────────────────────────────────────
113 "xtick.labelsize": fs - 1,
114 "ytick.labelsize": fs - 1,
115 "xtick.direction": "in",
116 "ytick.direction": "in",
117 "xtick.top": True,
118 "ytick.right": True,
119 "xtick.minor.visible": True,
120 "ytick.minor.visible": True,
121 "xtick.major.size": 4.0,
122 "ytick.major.size": 4.0,
123 "xtick.minor.size": 2.5,
124 "ytick.minor.size": 2.5,
125 "xtick.major.width": 0.8,
126 "ytick.major.width": 0.8,
127 "xtick.minor.width": 0.6,
128 "ytick.minor.width": 0.6,
129 "xtick.major.pad": 4.0,
130 "ytick.major.pad": 4.0,
131 # ── Legend ───────────────────────────────────────────────
132 "legend.fontsize": fs - 1,
133 "legend.frameon": False,
134 "legend.borderpad": 0.4,
135 "legend.labelspacing": 0.3,
136 "legend.handlelength": 1.8,
137 "legend.handletextpad": 0.5,
138 "legend.loc": "best",
139 # ── Lines ────────────────────────────────────────────────
140 "lines.linewidth": 1.2,
141 "lines.markersize": 4.0,
142 "lines.antialiased": True,
143 # ── Error bars ───────────────────────────────────────────
144 "errorbar.capsize": 3.0,
145 # ── Patches / contours ────────────────────────────────────
146 "patch.linewidth": 0.8,
147 "patch.antialiased": True,
148 # ── Colormaps ────────────────────────────────────────────
149 "image.cmap": "viridis",
150 "image.interpolation": "nearest",
151 # ── Color cycle ─────────────────────────────────────────────
152 "axes.prop_cycle": mpl.cycler(color=list(get_colors().values())),
153 }
154 )
156 @classmethod
157 def fig(cls, width: float | str = 1.0, aspect: float | None = None, **kwargs):
158 """
159 Single-panel figure. Returns (fig, ax)
160 :param width: fraction of textwidth as float, 0.5 = half, 1.0 = full
161 or inches as a string "3.5in"
162 :param aspect: height / width, default 1/golden ≈ 0.618
163 :return: (fig, ax)
164 """
165 return plt.subplots(figsize=cls.fig_size(width, aspect), **kwargs)
167 @classmethod
168 def fig_grid(
169 cls,
170 nrows: int = 1,
171 ncols: int = 2,
172 width: float | str = 1.0,
173 aspect: float | None = None,
174 **kwargs,
175 ):
176 """
177 Multi-panel figure. Returns (fig, axes).
178 :param nrows: number of rows
179 :param ncols: number of columns
180 :param width: fraction of textwidth as float, 0.5 = half, 1.0 = full
181 or inches as a string "3.5in"
182 :param aspect: height / width, default 1/golden ≈ 0.618
183 :return: (fig, axes)
184 """
185 return plt.subplots(nrows, ncols, figsize=cls.fig_size(width, aspect), **kwargs)