#!/usr/bin/env python3 """Render avalanche bit-probability CSV files as two PNG bar charts.""" from __future__ import annotations import argparse import csv import math from dataclasses import dataclass from pathlib import Path from statistics import fmean from typing import Sequence from PIL import Image, ImageDraw, ImageFont BACKGROUND = "#f7f8fa" PANEL = "#ffffff" GRID = "#d9dee7" TEXT = "#172033" MUTED = "#637083" REFERENCE = "#d24b4b" COLORS = ( "#377eb8", "#4daf4a", "#984ea3", "#ff7f00", "#e41a1c", "#00a6a6", "#a65628", ) @dataclass(frozen=True) class ProbabilityRow: operation: str pairs: int probabilities: list[float] @dataclass(frozen=True) class ChartScale: minimum: float maximum: float ticks: tuple[float, ...] def nice_step(value: float) -> float: exponent = math.floor(math.log10(value)) fraction = value / 10**exponent nice_fraction = min((1.0, 2.0, 2.5, 5.0, 10.0), key=lambda item: abs(item - fraction)) return nice_fraction * 10**exponent def make_scale( values: Sequence[float], *, hard_limits: tuple[float, float], reference: float | None = None, ) -> ChartScale: """Build a padded shared scale constrained to hard limits.""" hard_minimum, hard_maximum = hard_limits finite_values = [value for value in values if math.isfinite(value)] if reference is not None: finite_values.append(reference) if not finite_values: finite_values = [hard_minimum, hard_maximum] minimum = min(finite_values) maximum = max(finite_values) if minimum == maximum: expansion = (hard_maximum - hard_minimum) * 0.1 minimum -= expansion / 2 maximum += expansion / 2 span = maximum - minimum padded_minimum = max(hard_minimum, minimum - span * 0.1) padded_maximum = min(hard_maximum, maximum + span * 0.1) step = nice_step(max((padded_maximum - padded_minimum) / 5, 1e-12)) scaled_minimum = max(hard_minimum, math.floor(padded_minimum / step) * step) scaled_maximum = min(hard_maximum, math.ceil(padded_maximum / step) * step) if scaled_minimum == scaled_maximum: scaled_minimum, scaled_maximum = hard_minimum, hard_maximum tick_count = round((scaled_maximum - scaled_minimum) / step) ticks = [scaled_minimum + index * step for index in range(tick_count + 1)] if reference is not None and scaled_minimum <= reference <= scaled_maximum: ticks.append(reference) normalized_ticks = tuple( sorted({round(value, 12) for value in ticks if scaled_minimum <= value <= scaled_maximum}) ) return ChartScale(scaled_minimum, scaled_maximum, normalized_ticks) def load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: names = ( "DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf", "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/TTF/DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", ) for name in names: try: return ImageFont.truetype(name, size) except OSError: continue return ImageFont.load_default() def read_probability_map(path: Path) -> list[ProbabilityRow]: """Read operation rows and numerically ordered bit columns from a CSV file.""" try: stream = path.open(encoding="utf-8", newline="") except OSError as error: raise ValueError(f"не удалось открыть {path}: {error}") from error with stream: reader = csv.DictReader(stream) fields = reader.fieldnames if not fields or "operation" not in fields or "pairs" not in fields: raise ValueError("CSV должен содержать колонки operation и pairs") bit_fields: list[tuple[int, str]] = [] for field in fields: if not field.startswith("bit_"): continue try: bit_fields.append((int(field.removeprefix("bit_")), field)) except ValueError as error: raise ValueError(f"некорректная битовая колонка: {field}") from error bit_fields.sort() if not bit_fields: raise ValueError("CSV не содержит колонок bit_N") expected_bits = list(range(len(bit_fields))) actual_bits = [bit for bit, _field in bit_fields] if actual_bits != expected_bits: raise ValueError("битовые колонки должны непрерывно идти от bit_0") rows: list[ProbabilityRow] = [] for line_number, row in enumerate(reader, start=2): operation = (row.get("operation") or "").strip() if not operation: raise ValueError(f"строка {line_number}: пустая операция") try: pairs = int(row["pairs"] or "") except (TypeError, ValueError) as error: raise ValueError( f"строка {line_number}: некорректное число пар" ) from error if pairs < 0: raise ValueError(f"строка {line_number}: число пар меньше нуля") probabilities: list[float] = [] for _bit, field in bit_fields: try: value = float(row[field] or "") except (TypeError, ValueError) as error: raise ValueError( f"строка {line_number}: некорректное значение {field}" ) from error if not math.isnan(value) and not 0.0 <= value <= 1.0: raise ValueError( f"строка {line_number}: {field} должен быть от 0 до 1" ) probabilities.append(value) rows.append(ProbabilityRow(operation, pairs, probabilities)) if not rows: raise ValueError("CSV не содержит строк с операциями") return rows def mean_absolute_deviation(probabilities: Sequence[float]) -> float: """Return the mean |p - 0.5| over finite bit probabilities.""" deviations = [ abs(probability - 0.5) for probability in probabilities if math.isfinite(probability) ] return fmean(deviations) if deviations else float("nan") def text_width( draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFont | ImageFont.ImageFont, ) -> int: box = draw.textbbox((0, 0), text, font=font) return round(box[2] - box[0]) def draw_centered_text( draw: ImageDraw.ImageDraw, center_x: float, y: float, text: str, font: ImageFont.FreeTypeFont | ImageFont.ImageFont, fill: str = TEXT, ) -> None: draw.text( (center_x - text_width(draw, text, font) / 2, y), text, font=font, fill=fill, ) def draw_bit_panel( draw: ImageDraw.ImageDraw, bounds: tuple[int, int, int, int], row: ProbabilityRow, color: str, scale: ChartScale, ) -> None: left, top, right, bottom = bounds title_font = load_font(22, bold=True) label_font = load_font(14) tick_font = load_font(12) draw.rounded_rectangle(bounds, radius=12, fill=PANEL, outline=GRID, width=1) draw.text((left + 18, top + 13), row.operation, font=title_font, fill=TEXT) pairs_text = f"pairs: {row.pairs}" draw.text( (right - 18 - text_width(draw, pairs_text, label_font), top + 17), pairs_text, font=label_font, fill=MUTED, ) plot_left = left + 54 plot_right = right - 18 plot_top = top + 55 plot_bottom = bottom - 42 plot_height = plot_bottom - plot_top scale_span = scale.maximum - scale.minimum for probability in scale.ticks: y = round(plot_bottom - (probability - scale.minimum) / scale_span * plot_height) line_color = REFERENCE if probability == 0.5 else GRID line_width = 2 if probability == 0.5 else 1 draw.line((plot_left, y, plot_right, y), fill=line_color, width=line_width) label = f"{probability:.3g}" draw.text( (plot_left - 8 - text_width(draw, label, tick_font), y - 7), label, font=tick_font, fill=MUTED, ) bit_count = len(row.probabilities) slot_width = (plot_right - plot_left) / bit_count bar_width = max(1, int(slot_width * 0.72)) for bit, probability in enumerate(row.probabilities): if not math.isfinite(probability): continue center = plot_left + (bit + 0.5) * slot_width x0 = round(center - bar_width / 2) x1 = round(center + bar_width / 2) y = round( plot_bottom - (probability - scale.minimum) / scale_span * plot_height ) draw.rectangle((x0, y, x1, plot_bottom), fill=color) tick_step = max(1, math.ceil(bit_count / 16)) for bit in range(0, bit_count, tick_step): center = plot_left + (bit + 0.5) * slot_width label = str(bit) draw.text( (center - text_width(draw, label, tick_font) / 2, plot_bottom + 7), label, font=tick_font, fill=MUTED, ) draw_centered_text( draw, (plot_left + plot_right) / 2, bottom - 21, "output bit (0 = LSB)", tick_font, MUTED, ) def render_bit_probabilities( rows: Sequence[ProbabilityRow], output: Path, title: str ) -> None: columns = 2 if len(rows) > 1 else 1 panel_width = 760 panel_height = 330 gap = 18 margin = 24 title_height = 70 row_count = math.ceil(len(rows) / columns) width = margin * 2 + columns * panel_width + (columns - 1) * gap height = title_height + margin + row_count * panel_height + (row_count - 1) * gap image = Image.new("RGB", (width, height), BACKGROUND) draw = ImageDraw.Draw(image) scale = make_scale( [probability for row in rows for probability in row.probabilities], reference=0.5, hard_limits=(0.0, 1.0), ) draw_centered_text(draw, width / 2, 18, title, load_font(30, bold=True)) draw_centered_text( draw, width / 2, 52, f"Shared scale {scale.minimum:.3g}–{scale.maximum:.3g}; red line = ideal p=0.5", load_font(14), MUTED, ) for index, row in enumerate(rows): column = index % columns grid_row = index // columns left = margin + column * (panel_width + gap) top = title_height + grid_row * (panel_height + gap) draw_bit_panel( draw, (left, top, left + panel_width, top + panel_height), row, COLORS[index % len(COLORS)], scale, ) output.parent.mkdir(parents=True, exist_ok=True) image.save(output, "PNG", optimize=True) def render_mean_deviations( rows: Sequence[ProbabilityRow], output: Path, title: str ) -> None: width, height = 1200, 720 image = Image.new("RGB", (width, height), BACKGROUND) draw = ImageDraw.Draw(image) draw_centered_text(draw, width / 2, 22, title, load_font(30, bold=True)) draw_centered_text( draw, width / 2, 58, "Mean absolute deviation from ideal avalanche probability: mean(|p - 0.5|)", load_font(15), MUTED, ) plot_left, plot_right = 90, width - 40 plot_top, plot_bottom = 110, height - 120 plot_height = plot_bottom - plot_top deviations = [mean_absolute_deviation(row.probabilities) for row in rows] scale = make_scale(deviations, hard_limits=(0.0, 0.5)) scale_span = scale.maximum - scale.minimum tick_font = load_font(13) label_font = load_font(15) value_font = load_font(14, bold=True) for value in scale.ticks: y = round(plot_bottom - (value - scale.minimum) / scale_span * plot_height) draw.line((plot_left, y, plot_right, y), fill=GRID, width=1) label = f"{value:.3g}" draw.text( (plot_left - 10 - text_width(draw, label, tick_font), y - 7), label, font=tick_font, fill=MUTED, ) slot_width = (plot_right - plot_left) / len(rows) bar_width = min(105, max(20, int(slot_width * 0.62))) for index, (row, deviation) in enumerate(zip(rows, deviations, strict=True)): center = plot_left + (index + 0.5) * slot_width x0 = round(center - bar_width / 2) x1 = round(center + bar_width / 2) if math.isfinite(deviation): y = round( plot_bottom - (deviation - scale.minimum) / scale_span * plot_height ) draw.rectangle((x0, y, x1, plot_bottom), fill=COLORS[index % len(COLORS)]) value_label = f"{deviation:.4f}" else: y = plot_bottom value_label = "n/a" draw_centered_text(draw, center, max(plot_top, y - 22), value_label, value_font) draw_centered_text(draw, center, plot_bottom + 12, row.operation, label_font) draw_centered_text( draw, center, plot_bottom + 36, f"pairs: {row.pairs}", tick_font, MUTED ) draw.line((plot_left, plot_top, plot_left, plot_bottom), fill=TEXT, width=2) draw.line((plot_left, plot_bottom, plot_right, plot_bottom), fill=TEXT, width=2) output.parent.mkdir(parents=True, exist_ok=True) image.save(output, "PNG", optimize=True) def generate_plots(source: Path, output_directory: Path | None = None) -> tuple[Path, Path]: rows = read_probability_map(source) destination = output_directory or source.parent bit_output = destination / f"{source.stem}_bits.png" deviation_output = destination / f"{source.stem}_deviation.png" display_name = source.stem render_bit_probabilities(rows, bit_output, f"{display_name}: per-bit avalanche map") render_mean_deviations( rows, deviation_output, f"{display_name}: deviation from p=0.5", ) return bit_output, deviation_output def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( "Создаёт две PNG-гистограммы из CSV вероятностной карты: " "вероятности по битам для каждой операции и среднее |p-0.5|." ) ) parser.add_argument("csv_file", type=Path, help="CSV из probability_map.py") parser.add_argument( "-o", "--output-dir", type=Path, help="каталог PNG (по умолчанию каталог исходного CSV)", ) return parser def main(argv: Sequence[str] | None = None) -> int: args = build_parser().parse_args(argv) try: outputs = generate_plots(args.csv_file, args.output_dir) except ValueError as error: raise SystemExit(f"error: {error}") from error for output in outputs: print(f"wrote {output}") return 0 if __name__ == "__main__": raise SystemExit(main())