Source code for textcharts.normalized_speedup

"""ASCII normalized speedup chart showing relative performance vs a baseline."""

from __future__ import annotations

import logging
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING

from textcharts.base import ChartBase, ChartOptions

if TYPE_CHECKING:
    from collections.abc import Sequence

logger = logging.getLogger(__name__)


[docs] @dataclass class SpeedupData: """Normalized speedup data for a single platform.""" name: str ratio: float # e.g. 2.0 = 2x faster than baseline, 0.5 = 2x slower is_baseline: bool = False
[docs] class NormalizedSpeedup(ChartBase): """Normalized speedup chart showing platform performance relative to a baseline. Bars extend right for faster-than-baseline ratios and left for slower, with a vertical reference line at the baseline (1.0x). Uses log2 scaling so that 2x and 0.5x are symmetric around the baseline. Example output: ``` Normalized Performance (baseline: SQLite = 1.0x) ───────────────────────────────────────────────── Slower │ Faster SQLite │█ 1.0x Pandas ██████│ 0.4x DuckDB │████████████████████ 8.2x ``` """ def __init__( self, data: Sequence[SpeedupData], title: str | None = None, baseline_name: str | None = None, options: ChartOptions | None = None, subtitle: str | None = None, subject: str | None = None, ): super().__init__(options, subtitle=subtitle, title=title, subject=subject) self.data = list(data) self.baseline_name = baseline_name # Title is dynamic (depends on baseline); _compose_title only used when # caller provides an explicit title or subject. if self._explicit_title is not None or self._subject is not None: self.title = self._compose_title("Normalized Performance") else: self.title = None
[docs] def render(self) -> str: """Render the normalized speedup chart as a string.""" self._detect_capabilities() if not self.data: return "No data to display" colors = self.options.get_colors() width = self.options.get_effective_width() box = self.options.get_box_chars() # Determine baseline baseline = self._find_baseline() # Build title (explicit options.title takes precedence) title = self.options.title or self.title if not title: if baseline: title = f"Normalized Performance (baseline: {baseline} = 1.0x)" else: title = "Normalized Performance" # Layout calculation max_label_len = min(25, max(len(d.name) for d in self.data)) annotation_width = 8 # " 12.3x" bar_area = width - max_label_len - 1 - 1 - 1 - annotation_width half_bar = max(5, bar_area // 2) # Compute log2 scale: find max |log2(ratio)| for scaling # Clamp extreme ratios to avoid overflow; treat non-positive as very slow _MIN_RATIO = 1e-6 _MAX_RATIO = 1e6 log_ratios = [] for d in self.data: clamped = max(_MIN_RATIO, min(_MAX_RATIO, d.ratio)) if d.ratio > 0 else _MIN_RATIO log_ratios.append(math.log2(clamped)) max_log = max((abs(lr) for lr in log_ratios), default=1.0) if max_log <= 0: max_log = 1.0 lines: list[str] = [] # Title and subtitle lines.append(self._render_title(title, width)) subtitle = self._render_subtitle(width) if subtitle: lines.append(subtitle) lines.append(self._render_horizontal_line(width)) # Header slower_label = "Slower" faster_label = "Faster" header_left = slower_label.rjust(max_label_len + 1 + half_bar) header_right = f" {faster_label}" lines.append(f"{header_left}{box['v']}{header_right}") # Determine fill character fill = self.options.get_horizontal_block_chars()[-1] # Full block # Sort: baseline first, then by ratio descending sorted_data = sorted( zip(self.data, log_ratios), key=lambda pair: (not pair[0].is_baseline, -pair[0].ratio), ) for datum, log_ratio in sorted_data: label = self._truncate_label(datum.name, max_label_len).ljust(max_label_len) bar_len = max(0, int((abs(log_ratio) / max_log) * half_bar)) # Ensure baseline always gets at least 1 char if datum.is_baseline and bar_len == 0: bar_len = 1 if log_ratio >= 0: # Faster or baseline: bar extends right left_side = " " * half_bar right_side = (fill * bar_len).ljust(half_bar) colored_left = left_side colored_right = colors.colorize(right_side, fg_color="#1b9e77") else: # Slower: bar extends left left_side = (fill * bar_len).rjust(half_bar) right_side = " " * half_bar colored_left = colors.colorize(left_side, fg_color="#d95f02") colored_right = right_side # Format annotation annotation = self._format_ratio(datum.ratio).rjust(annotation_width) # Colorize label if datum.is_baseline: colored_label = colors.colorize(label, fg_color="#666666") else: fg = "#1b9e77" if log_ratio >= 0 else "#d95f02" colored_label = colors.colorize(label, fg_color=fg) lines.append(f"{colored_label} {colored_left}{box['v']}{colored_right} {annotation}") lines.append(self._render_horizontal_line(width)) return "\n".join(lines)
def _find_baseline(self) -> str | None: """Find the baseline platform name.""" if self.baseline_name: return self.baseline_name for d in self.data: if d.is_baseline: return d.name return None @staticmethod def _format_ratio(ratio: float) -> str: """Format a speedup ratio for display.""" if not math.isfinite(ratio) or ratio <= 0: return "N/A" if ratio >= 100: return f"{ratio:.0f}x" if ratio >= 10: return f"{ratio:.1f}x" return f"{ratio:.2f}x"
[docs] def from_ratios( platform_times: Sequence[tuple[str, float]], baseline: str | None = None, title: str | None = None, options: ChartOptions | None = None, subject: str | None = None, ) -> NormalizedSpeedup: """Create NormalizedSpeedup from platform timing data. Args: platform_times: Sequence of (platform_name, total_time_ms) tuples. baseline: Name of baseline platform, or "slowest"/"fastest" for auto-selection. title: Optional chart title. options: Chart rendering options. Returns: Configured NormalizedSpeedup instance. """ if not platform_times: return NormalizedSpeedup(data=[], title=title, options=options, subject=subject) # Select baseline if baseline == "slowest": baseline_name = max(platform_times, key=lambda x: x[1])[0] elif baseline == "fastest": baseline_name = min(platform_times, key=lambda x: x[1])[0] elif baseline: baseline_name = baseline else: # Default: use the slowest platform baseline_name = max(platform_times, key=lambda x: x[1])[0] # Find baseline time baseline_time = next((t for n, t in platform_times if n == baseline_name), None) if baseline_time is None or baseline_time <= 0: baseline_time = 1.0 converted: list[SpeedupData] = [] for name, time_ms in platform_times: ratio = baseline_time / time_ms if time_ms > 0 else 0.0 converted.append(SpeedupData(name=name, ratio=ratio, is_baseline=(name == baseline_name))) return NormalizedSpeedup( data=converted, title=title, baseline_name=baseline_name, options=options, subject=subject, )