Shamrock 2025.10.0
Astrophysical Code
Loading...
Searching...
No Matches
benchmark.py
1"""
2Helpers for building standardized benchmark plots: several series on a
3log-log scale, each ending in a non-overlapping value callout, with a
4legend row underneath. Used across Shamrock's benchmark examples.
5"""
6
7import matplotlib.pyplot as plt
8
9__all__ = ["add_end_labels", "make_std_bench_plot"]
10
11
12def add_end_labels(ax_main, ax_annot, entries, x_pad=0.05, min_gap_px=25, fontsize=9):
13 """
14 Place non-overlapping value callouts in a dedicated annotation axes.
15
16 `ax_annot` must share its y-axis with `ax_main` (i.e. have been created
17 with ``sharey=ax_main``), so a "data" y-coordinate means the same thing
18 in either axes. Each callout is linked back to its line's last data
19 point in `ax_main` with a leader line.
20
21 Parameters
22 ----------
23 ax_main : matplotlib.axes.Axes
24 The axes holding the plotted lines.
25 ax_annot : matplotlib.axes.Axes
26 The (typically narrow, spine-less) axes the callout text is drawn
27 into. Must share its y-axis with `ax_main`.
28 entries : list of (float, float, str, color)
29 One ``(x, y, text, color)`` tuple per callout, where ``(x, y)`` is
30 the data point the leader line points to.
31 x_pad : float, optional
32 Horizontal position of the callout text, in `ax_annot` axes-fraction
33 coordinates.
34 min_gap_px : float, optional
35 Minimum vertical spacing between callouts, in display pixels.
36 fontsize : float, optional
37 Font size of the callout text.
38 """
39 if not entries:
40 return
41
42 # Sort by data y-value and convert to display (pixel) coordinates so
43 # spacing can be reasoned about independently of the (log) data scale
44 order = sorted(range(len(entries)), key=lambda i: entries[i][1])
45 disp_y = [ax_main.transData.transform((0, entries[i][1]))[1] for i in order]
46
47 # Group overlapping labels into clusters and spread each cluster
48 # symmetrically around the mean of its members' true positions, rather
49 # than cascading everything upward when things get crammed
50 clusters = [] # each: {"center": mean y, "count": n}
51 for y in disp_y:
52 clusters.append({"center": y, "count": 1})
53 while len(clusters) >= 2:
54 a, b = clusters[-2], clusters[-1]
55 span_a = (a["count"] - 1) * min_gap_px
56 span_b = (b["count"] - 1) * min_gap_px
57 top_a = a["center"] + span_a / 2
58 bot_b = b["center"] - span_b / 2
59 if bot_b - top_a < min_gap_px:
60 count = a["count"] + b["count"]
61 center = (a["center"] * a["count"] + b["center"] * b["count"]) / count
62 clusters[-2:] = [{"center": center, "count": count}]
63 else:
64 break
65
66 disp_y = []
67 for c in clusters:
68 span = (c["count"] - 1) * min_gap_px
69 start = c["center"] - span / 2
70 disp_y.extend(start + k * min_gap_px for k in range(c["count"]))
71
72 # ax_annot shares its y-axis with ax_main, so a "data" y-coordinate
73 # means the same thing in either axes
74 inv = ax_main.transData.inverted()
75 for idx, y_disp in zip(order, disp_y):
76 x_data, y_data, text, color = entries[idx]
77 label_y_data = inv.transform((0, y_disp))[1]
78 # mirror the bend when the label lands below its point, otherwise the
79 # corner ends up on the wrong side and the leader line doubles back
80 angle_b = 60 if label_y_data >= y_data else -60
81 ax_annot.annotate(
82 text,
83 xy=(x_data, y_data),
84 xycoords=ax_main.transData,
85 xytext=(x_pad, label_y_data),
86 textcoords=("axes fraction", "data"),
87 color=color,
88 fontsize=fontsize,
89 va="center",
90 ha="left",
91 annotation_clip=False,
92 bbox=dict(boxstyle="round", fc="0.8"),
93 arrowprops=dict(
94 arrowstyle="-",
95 color=color,
96 lw=0.8,
97 shrinkA=0,
98 shrinkB=2,
99 connectionstyle=f"angle,angleA=0,angleB={angle_b},rad=10",
100 ),
101 )
102
103
105 plot_data,
106 xlabel,
107 ylabel,
108 title,
109 end_label_fmt=lambda y: f"{y:.2f}",
110 before_plot_func=None,
111 dpi=250,
112 figsize=(8, 6),
113 min_gap_px=75,
114 legend_ncol=2,
115):
116 """
117 Build a standardized benchmark plot.
118
119 Layout: a log-log plot on the left (5/6 of the width), a value-callout
120 panel on the right (1/6), and a legend row spanning the full width
121 underneath.
122
123 Parameters
124 ----------
125 plot_data : dict
126 Maps a series key to a dict with keys ``x``, ``y``, ``color``,
127 ``label``, ``linestyle`` and ``marker``, one entry per line to plot.
128 xlabel, ylabel, title : str
129 Axis labels and title for the main plot.
130 end_label_fmt : callable, optional
131 Formats a series' last y-value into its callout text.
132 before_plot_func : callable, optional
133 Called as ``before_plot_func(ax_main)`` before the series in
134 `plot_data` are plotted, e.g. to draw reference lines underneath
135 them.
136 dpi, figsize : optional
137 Passed to `matplotlib.pyplot.figure`.
138 min_gap_px : float, optional
139 Minimum vertical spacing between callouts, in display pixels; see
140 `add_end_labels`.
141 legend_ncol : int, optional
142 Number of columns in the legend row.
143
144 Returns
145 -------
146 (matplotlib.figure.Figure, matplotlib.axes.Axes)
147 The created figure and its main (plot) axes.
148 """
149 # Layout: 75%/25% split between the plot and its annotation panel on
150 # top, with a legend row spanning the full width underneath
151 fig = plt.figure(dpi=dpi, figsize=figsize)
152 gs = fig.add_gridspec(
153 2, 2, width_ratios=[5, 1], height_ratios=[5, 1.5], hspace=0.35, wspace=0.05
154 )
155 ax_main = fig.add_subplot(gs[0, 0])
156 ax_annot = fig.add_subplot(gs[0, 1], sharey=ax_main)
157 ax_legend = fig.add_subplot(gs[1, :])
158 ax_annot.axis("off")
159 ax_legend.axis("off")
160
161 # finalize the outer figure margins before anything layout-dependent (the
162 # end-label declutter math) reads axes geometry off of ax_main
163 fig.subplots_adjust(left=0.1, right=0.99, top=0.94, bottom=0.06)
164
165 if before_plot_func is not None:
166 before_plot_func(ax_main)
167
168 end_labels = []
169 for d in plot_data.values():
170 ax_main.plot(
171 d["x"],
172 d["y"],
173 d["linestyle"],
174 color=d["color"],
175 label=d["label"],
176 marker=d["marker"],
177 )
178 end_labels.append((d["x"][-1], d["y"][-1], end_label_fmt(d["y"][-1]), d["color"]))
179
180 ax_main.set_xlabel(xlabel)
181 ax_main.set_ylabel(ylabel)
182 ax_main.set_title(title)
183
184 ax_main.set_xscale("log")
185 ax_main.set_yscale("log")
186
187 ax_main.grid(True)
188
189 add_end_labels(ax_main, ax_annot, end_labels, min_gap_px=min_gap_px)
190
191 handles, labels = ax_main.get_legend_handles_labels()
192 ax_legend.legend(handles, labels, loc="center", ncol=legend_ncol, fontsize=10)
193
194 return fig, ax_main
make_std_bench_plot(plot_data, xlabel, ylabel, title, end_label_fmt=lambda y:f"{y:.2f}", before_plot_func=None, dpi=250, figsize=(8, 6), min_gap_px=75, legend_ncol=2)
Definition benchmark.py:115
add_end_labels(ax_main, ax_annot, entries, x_pad=0.05, min_gap_px=25, fontsize=9)
Definition benchmark.py:12