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

1# Copyright (C) 2026 ETH Zurich 

2# Institute for Particle Physics and Astrophysics 

3# Author: Silvan Fischbacher 

4# created: Thu Mar 26 2026 

5 

6 

7import shutil 

8import warnings 

9 

10import matplotlib as mpl 

11import matplotlib.pyplot as plt 

12 

13from cosmic_toolbox.colors import get_colors 

14 

15 

16def _latex_available() -> bool: 

17 """Check if LaTeX is available on the system.""" 

18 return shutil.which("latex") is not None 

19 

20 

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 ) 

29 

30 # ── Derived geometry (do not override) ─────────────────────────────── 

31 PT_PER_INCH = 72.27 

32 GOLDEN = (1 + 5**0.5) / 2 

33 

34 @classmethod 

35 def textwidth(cls) -> float: 

36 """ 

37 Full textwidth in inches. 

38 """ 

39 return cls.TEXTWIDTH_PT / cls.PT_PER_INCH 

40 

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 

49 

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. 

54 

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) 

64 

65 h = w / cls.GOLDEN if aspect is None else w * aspect 

66 return (w, h) 

67 

68 @classmethod 

69 def set_mpl(cls, use_latex: bool | None = None): 

70 """ 

71 Apply this journal's rcParams globally. 

72 

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 ) 

155 

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) 

166 

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)