33import shutil
44from collections .abc import Sequence
55
6+ _BLOCKS = ' ▁▂▃▄▅▆▇█'
7+
8+
9+ def _format_duration (t : float ) -> str :
10+ """Format *t* seconds into a compact human-readable string."""
11+ if t >= 1.0 :
12+ return f'{ t :.3f} s'
13+ if t >= 1e-3 :
14+ return f'{ t * 1e3 :.2f} ms'
15+ if t >= 1e-6 :
16+ return f'{ t * 1e6 :.2f} \u03bc s'
17+ return f'{ t * 1e9 :.2f} ns'
18+
619
720def terminal_width () -> int :
821 """Return the current terminal width, clamped to a minimum of 20."""
@@ -77,22 +90,58 @@ def pad_inner_line(line: str) -> str:
7790 return result
7891
7992
93+ def histogram_bounds (durations : Sequence [float ]) -> tuple [float , float ]:
94+ """Return ``(lo, hi)`` bounds for a histogram: minimum and p99 value.
95+
96+ The p99 clip prevents extreme outliers from compressing the bulk of the
97+ distribution into the leftmost column.
98+
99+ Args:
100+ durations: Sequence of per-call timings in seconds.
101+ """
102+ sorted_durs = sorted (durations )
103+ lo = sorted_durs [0 ]
104+ p99_idx = min (len (sorted_durs ) - 1 , int (len (sorted_durs ) * 0.99 ))
105+ return lo , sorted_durs [p99_idx ]
106+
107+
108+ def draw_histogram_axis (lo : float , hi : float , width : int ) -> str :
109+ """Return a single-line x-axis label for a histogram with bounds *lo*/*hi*.
110+
111+ The minimum value is left-aligned; the p99 clip value is right-aligned.
112+ Both are formatted with auto-selected time units (ns / μs / ms / s).
113+
114+ Args:
115+ lo: Minimum value displayed on the x-axis.
116+ hi: Maximum value (p99 clip point) displayed on the x-axis.
117+ width: Total width of the label in characters.
118+ """
119+ if width < 1 :
120+ return ''
121+ left = _format_duration (lo )
122+ right = _format_duration (hi ) + ' (p99)'
123+ if len (left ) + 1 + len (right ) > width :
124+ return left [:width ]
125+ spaces = width - len (left ) - len (right )
126+ return left + ' ' * spaces + right
127+
128+
80129def draw_histogram (durations : Sequence [float ], width : int , height : int ) -> list [str ]:
81130 """Render an ASCII bar chart of *durations* as a ``height`` x ``width`` grid.
82131
83- The output is ``width`` characters wide and ``height`` rows tall. Filled
84- cells use ``'█'``; empty cells use ``' '``. Returns an empty list when any
85- dimension is zero/negative or when *durations* is empty.
132+ The output is ``width`` characters wide and ``height`` rows tall. Each
133+ cell uses one of the Unicode block characters ``' ▁▂▃▄▅▆▇█'`` so bar
134+ heights are resolved at 1/8-row precision, giving smooth transitions
135+ between adjacent buckets. Returns an empty list when any dimension is
136+ zero/negative or when *durations* is empty.
86137
87138 Internally the data is bucketed into at most 20 bins regardless of
88139 *width*. Each bin is then rendered as ``width // n_buckets`` characters
89- wide. This prevents timer-quantisation artefacts (where most measurements
90- snap to a handful of discrete nanosecond values) from producing a single
91- column spike with stray isolated pixels elsewhere.
140+ wide. This prevents timer-quantisation artefacts from producing a single
141+ spike with stray isolated pixels.
92142
93- The x-axis upper bound is clipped to the p99 value to prevent extreme
94- outliers from compressing the bulk of the distribution. Values above the
95- p99 clip point are omitted from buckets.
143+ The x-axis upper bound is clipped to the p99 value via
144+ :func:`histogram_bounds`. Values above the clip point are omitted.
96145
97146 When all values are identical (``hi == lo``), the middle bin is drawn
98147 full-height and all others are left empty.
@@ -105,14 +154,10 @@ def draw_histogram(durations: Sequence[float], width: int, height: int) -> list[
105154 if width < 1 or height < 1 or len (durations ) == 0 :
106155 return []
107156
108- sorted_durs = sorted (durations )
109- lo = sorted_durs [0 ]
110- p99_idx = min (len (sorted_durs ) - 1 , int (len (sorted_durs ) * 0.99 ))
111- hi = sorted_durs [p99_idx ]
157+ lo , hi = histogram_bounds (durations )
112158
113- # Cap the number of data buckets at 20 so that each bucket spans a
114- # visible fraction of the output width and adjacent quantised values are
115- # merged into the same bar.
159+ # Cap the number of data buckets at 20 so that adjacent quantised timer
160+ # values are merged into the same wider bar.
116161 n_buckets = min (width , 20 )
117162 counts = [0 ] * n_buckets
118163
@@ -127,16 +172,22 @@ def draw_histogram(durations: Sequence[float], width: int, height: int) -> list[
127172 counts [idx ] += 1
128173
129174 max_count = max (counts )
130- bar_heights = [
131- round ( c / max_count * height ) if c > 0 else 0
175+ bar_heights_float = [
176+ c / max_count * height if c > 0 else 0. 0
132177 for c in counts
133178 ]
134179
135180 rows : list [str ] = []
136181 for row in range (height - 1 , - 1 , - 1 ):
137- line = '' .join (
138- '█' if bar_heights [col * n_buckets // width ] > row else ' '
139- for col in range (width )
140- )
141- rows .append (line )
182+ line_chars : list [str ] = []
183+ for col in range (width ):
184+ bh = bar_heights_float [col * n_buckets // width ]
185+ if bh >= row + 1 :
186+ line_chars .append ('█' )
187+ elif bh > row :
188+ frac = bh - row
189+ line_chars .append (_BLOCKS [max (1 , min (8 , round (frac * 8 )))])
190+ else :
191+ line_chars .append (' ' )
192+ rows .append ('' .join (line_chars ))
142193 return rows
0 commit comments