diff --git a/AGENTS.md b/AGENTS.md index e1fbd5d5..76502305 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,7 +151,6 @@ ckpt intermittent schematicO3 [BENCHMARKS...] --trace 1,2 [--cap board] [--devic # Analysis ckpt analyze strip-mining LOG_FILE [-o CSV] ckpt analyze milp-coarse ... # coarse-allocation MILP analysis -ckpt analyze plot CSV_DIR [--metric M] [--algorithms A...] # Device interaction ckpt device read-serial [--timeout N] [--end-marker M] @@ -206,8 +205,7 @@ scripts/ckpt/ │ └── schematic.py # SCHEMATIC semantic verification └── analysis/ ├── strip_mining.py # Parse verbose logs for strip-mining K values - ├── milp_coarse.py # Coarse-allocation MILP analysis - └── plot.py # Plot benchmark CSV results (requires `uv sync --extra plot`) + └── milp_coarse.py # Coarse-allocation MILP analysis ``` ### Standalone Scripts @@ -216,9 +214,11 @@ scripts/ckpt/ # Visualize results from a result directory Rscript scripts/plot_results.R [--result-dir DIR] [--output-dir DIR] [--absolute] [--all-metrics] [--benchmarks B,...] [--metrics M,...] [--log-scale] [--config FILE] +# Box plot of intermittent-power results (results/intermittent/summary.csv) +Rscript scripts/plot_intermittent.R [--result-dir DIR] [--output-dir DIR] ``` -`scripts/plot_results.R` produces per-capacitor bar charts, by default `execution_time` and `runtime_region_boundary_calls` normalized against the baseline marked `normalize_ref` in the config (falling back to the first algorithm). `--all-metrics` adds `region_boundaries`, `profiling_time`, and `compilation_time`; `--absolute` turns normalization off. Region boundary data comes from the device-debug CSVs, timing data from the non-debug ones. Which CSVs are read, and their labels/colors/patterns, come from a JSON config — no filename is hardcoded in the R script. `scripts/plot_config.json` is the default; `scripts/plot_config_o0.json` additionally plots `uninstrumentedO0.csv` as a second execution-time baseline (`--config scripts/plot_config_o0.json`). Each config lists `algorithms` (per-capacitor series; CSV names default to `.csv` / `_debug.csv`, overridable with `csv` / `debug_csv`) and `baselines` (capacitor-independent series; `csv` required, optional `metrics` restricts which metrics they appear in). +`scripts/plot_results.R` produces per-capacitor bar charts, by default `execution_time` and `runtime_region_boundary_calls` normalized against the baseline marked `normalize_ref` in the config (falling back to the first algorithm). `--all-metrics` adds `region_boundaries`, `profiling_time`, and `compilation_time`; `--absolute` turns normalization off. Region boundary data comes from the device-debug CSVs, timing data from the non-debug ones. Which CSVs are read, and their labels/colors/patterns, come from a JSON config — no filename is hardcoded in the R script. `scripts/plot_config.json` is the default; `scripts/plot_config_o0.json` additionally plots `uninstrumentedO0.csv` as a second execution-time baseline (`--config scripts/plot_config_o0.json`). Each config lists `algorithms` (per-capacitor series; CSV names default to `.csv` / `_debug.csv`, overridable with `csv` / `debug_csv`) and `baselines` (capacitor-independent series; `csv` required, optional `metrics` restricts which metrics they appear in). `scripts/plot_intermittent.R` takes its series labels, colors, and fill patterns from `scripts/plot_config.json` too; shared helpers live in `scripts/plot_common.R`. ## Architecture diff --git a/results/intermittent/normalized_time_box.pdf b/results/intermittent/normalized_time_box.pdf index 660c09f3..3e5fe39e 100644 Binary files a/results/intermittent/normalized_time_box.pdf and b/results/intermittent/normalized_time_box.pdf differ diff --git a/results/plots/execution_time_10uF_normalized.pdf b/results/plots/execution_time_10uF_normalized.pdf index a9ec3b85..10ac1b0d 100644 Binary files a/results/plots/execution_time_10uF_normalized.pdf and b/results/plots/execution_time_10uF_normalized.pdf differ diff --git a/results/plots/execution_time_50uF_normalized.pdf b/results/plots/execution_time_50uF_normalized.pdf index dad6eef4..6733db73 100644 Binary files a/results/plots/execution_time_50uF_normalized.pdf and b/results/plots/execution_time_50uF_normalized.pdf differ diff --git a/results/plots/execution_time_5uF_normalized.pdf b/results/plots/execution_time_5uF_normalized.pdf index 762c848f..32e6ec3c 100644 Binary files a/results/plots/execution_time_5uF_normalized.pdf and b/results/plots/execution_time_5uF_normalized.pdf differ diff --git a/results/plots/runtime_region_boundary_calls_10uF.pdf b/results/plots/runtime_region_boundary_calls_10uF.pdf deleted file mode 100644 index 343cce69..00000000 Binary files a/results/plots/runtime_region_boundary_calls_10uF.pdf and /dev/null differ diff --git a/results/plots/runtime_region_boundary_calls_10uF_normalized.pdf b/results/plots/runtime_region_boundary_calls_10uF_normalized.pdf index e876c58d..07a5fa29 100644 Binary files a/results/plots/runtime_region_boundary_calls_10uF_normalized.pdf and b/results/plots/runtime_region_boundary_calls_10uF_normalized.pdf differ diff --git a/results/plots/runtime_region_boundary_calls_50uF.pdf b/results/plots/runtime_region_boundary_calls_50uF.pdf deleted file mode 100644 index 3d9d4a04..00000000 Binary files a/results/plots/runtime_region_boundary_calls_50uF.pdf and /dev/null differ diff --git a/results/plots/runtime_region_boundary_calls_50uF_normalized.pdf b/results/plots/runtime_region_boundary_calls_50uF_normalized.pdf index 307e5107..acac40f4 100644 Binary files a/results/plots/runtime_region_boundary_calls_50uF_normalized.pdf and b/results/plots/runtime_region_boundary_calls_50uF_normalized.pdf differ diff --git a/results/plots/runtime_region_boundary_calls_5uF.pdf b/results/plots/runtime_region_boundary_calls_5uF.pdf deleted file mode 100644 index c304a064..00000000 Binary files a/results/plots/runtime_region_boundary_calls_5uF.pdf and /dev/null differ diff --git a/results/plots/runtime_region_boundary_calls_5uF_normalized.pdf b/results/plots/runtime_region_boundary_calls_5uF_normalized.pdf index c00a0d50..89540e26 100644 Binary files a/results/plots/runtime_region_boundary_calls_5uF_normalized.pdf and b/results/plots/runtime_region_boundary_calls_5uF_normalized.pdf differ diff --git a/scripts/ckpt/analysis/plot.py b/scripts/ckpt/analysis/plot.py deleted file mode 100644 index 27abaa77..00000000 --- a/scripts/ckpt/analysis/plot.py +++ /dev/null @@ -1,299 +0,0 @@ -"""Benchmark result plotting. - -Absorbs scripts/plot_benchmarks.py. The matplotlib import is optional -- -a clear error is raised if it is not installed. -""" - -from __future__ import annotations - -import csv -import logging -from pathlib import Path - -from ..bench.config import cap_sort_key -from ..errors import ConfigError - -logger = logging.getLogger(__name__) - -ALGORITHMS = { - "milp": { - "file": "milp_benchmark_summary.csv", - "label": "MILP", - "color": "#2196F3", - }, - "rockclimb": { - "file": "rockclimb_benchmark_summary.csv", - "label": "RockClimb (Machine)", - "color": "#FF9800", - }, - "schematic": { - "file": "schematic_benchmark_summary.csv", - "label": "SCHEMATIC", - "color": "#4CAF50", - }, - "schematicO3": { - "file": "schematicO3_benchmark_summary.csv", - "label": "SCHEMATIC-O3", - "color": "#8BC34A", - }, -} - -METRICS = { - "prologue": { - "column": "runtime_region_prologue_calls", - "ylabel": "Number of Runtime Region Prologue Calls", - "title": "Runtime Region Prologue Calls", - }, - "compilation_time": { - "column": "compilation_time_ms", - "ylabel": "Compilation Time (ms)", - "title": "Compilation Time", - }, - "profiling_time": { - "column": "profiling_time_ms", - "ylabel": "Profiling Time (ms)", - "title": "Profiling Time", - }, - "peak_memory": { - "column": "peak_rss_kb", - "ylabel": "Peak RSS (KB)", - "title": "Peak Memory Usage", - }, - "checkpoint_store_reg_calls": { - "column": "runtime_checkpoint_store_reg_calls", - "ylabel": "Number of checkpoint store register calls", - "title": "Checkpoint Store Register Calls", - }, - "checkpoint_restore_reg_calls": { - "column": "runtime_restore_reg_calls", - "ylabel": "Number of checkpoint restore register calls", - "title": "Checkpoint Restore Register Calls", - }, -} - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _read_csv(filepath: Path) -> list[dict[str, str]]: - """Read a CSV file and return rows as list of dicts.""" - with filepath.open(newline="") as f: - return list(csv.DictReader(f)) - - -def _parse_benchmark_name(benchmark: str) -> tuple[str, str]: - """Split 'program-capacitor' into (program, capacitor).""" - parts = benchmark.rsplit("-", 1) - if len(parts) == 2: - return parts[0], parts[1] - return benchmark, "" - - -def _get_value(row: dict[str, str], metric_key: str) -> float | None: - """Extract a numeric value from a CSV row.""" - col = METRICS[metric_key]["column"] - val = row.get(col, "") - if not val: - return None - try: - return float(val) - except ValueError: - return None - - -def _sort_key(label: str) -> tuple[str, float, str]: - parts = label.split("\n") - program = parts[0] - cap = parts[1].strip("()") if len(parts) > 1 else "" - cap_value, cap_label = cap_sort_key(cap) - return (program, cap_value, cap_label) - - -def _load_data( - csv_dir: Path, - algorithms: list[str], - benchmarks: list[str] | None, - capacitors: list[str] | None, - metric_key: str, -) -> tuple[dict[str, dict[str, float]], list[str]]: - """Load and filter data from CSV files. - - Returns: - data: dict[algorithm_key] -> dict[benchmark_label] -> value - benchmark_labels: ordered list of benchmark labels to plot - """ - data: dict[str, dict[str, float]] = {} - all_labels: set[str] = set() - - for alg_key in algorithms: - alg = ALGORITHMS[alg_key] - filepath = csv_dir / alg["file"] - if not filepath.exists(): - logger.warning("%s not found, skipping %s", filepath, alg_key) - continue - - rows = _read_csv(filepath) - alg_data: dict[str, float] = {} - - for row in rows: - bm = row.get("benchmark", "").strip() - if not bm: - continue - program, capacitor = _parse_benchmark_name(bm) - - if benchmarks and program not in benchmarks: - continue - if capacitors and capacitor not in capacitors: - continue - - label = f"{program}\n({capacitor})" - val = _get_value(row, metric_key) - if val is not None: - alg_data[label] = val - all_labels.add(label) - - data[alg_key] = alg_data - - benchmark_labels = sorted(all_labels, key=_sort_key) - return data, benchmark_labels - - -def _normalize_data( - data: dict[str, dict[str, float]], - benchmark_labels: list[str], - baseline_alg: str, -) -> tuple[dict[str, dict[str, float]], list[str]]: - """Normalize all values relative to the baseline algorithm (baseline = 1.0).""" - baseline_data = data.get(baseline_alg, {}) - normalized: dict[str, dict[str, float]] = {} - kept_labels: list[str] = [] - - for label in benchmark_labels: - base_val = baseline_data.get(label) - if not base_val or base_val == 0: - continue - kept_labels.append(label) - for alg_key, alg_data in data.items(): - if alg_key not in normalized: - normalized[alg_key] = {} - val = alg_data.get(label) - if val is not None: - normalized[alg_key][label] = val / base_val - - return normalized, kept_labels - - -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - - -def plot_benchmarks( - *, - csv_dir: Path, - metric: str, - algorithms: list[str] | None, - benchmarks: list[str] | None, - capacitors: list[str] | None, - normalize: str | None, - output_file: Path | None, -) -> None: - """Plot benchmark comparison chart. - - Requires ``matplotlib`` and ``numpy`` (install with - ``pip install checkpoint-insertion[plot]``). - """ - try: - import matplotlib.pyplot as plt # type: ignore[import-untyped] - import numpy as np # type: ignore[import-untyped] - except ImportError as exc: - raise ImportError( - "Plotting requires matplotlib and numpy. " - "Install with: pip install checkpoint-insertion[plot]" - ) from exc - - if metric not in METRICS: - raise ConfigError( - f"Unknown metric: {metric!r}. Available: {', '.join(METRICS)}" - ) - - alg_keys = algorithms if algorithms else list(ALGORITHMS.keys()) - metric_info = METRICS[metric] - - data, labels = _load_data(csv_dir, alg_keys, benchmarks, capacitors, metric) - - if normalize: - if normalize not in data: - logger.error("Baseline algorithm '%s' has no data.", normalize) - return - data, labels = _normalize_data(data, labels, normalize) - - n_benchmarks = len(labels) - n_algorithms = len([a for a in alg_keys if a in data]) - - if n_benchmarks == 0: - logger.warning("No data to plot.") - return - - _fig, ax = plt.subplots(figsize=(max(10, n_benchmarks * 1.2), 6)) - - bar_width = 0.8 / n_algorithms - x = np.arange(n_benchmarks) - - bar_idx = 0 - for alg_key in alg_keys: - if alg_key not in data: - continue - alg = ALGORITHMS[alg_key] - alg_data = data[alg_key] - - values = [alg_data.get(label, 0) for label in labels] - - offset = (bar_idx - (n_algorithms - 1) / 2) * bar_width - bars = ax.bar( - x + offset, - values, - bar_width, - label=alg["label"], - color=alg["color"], - edgecolor="white", - linewidth=0.5, - ) - - # Add value labels on bars - for bar, val in zip(bars, values): - if val > 0: - ax.text( - bar.get_x() + bar.get_width() / 2, - bar.get_height(), - f"{val:.2f}" if normalize else f"{val:g}", - ha="center", - va="bottom", - fontsize=7, - rotation=45, - ) - - bar_idx += 1 - - ax.set_xlabel("Benchmark") - if normalize: - baseline_label = ALGORITHMS[normalize]["label"] - ax.set_ylabel(f"Normalized to {baseline_label} (ratio)") - ax.set_title(f"{metric_info['title']} (Normalized to {baseline_label})") - ax.axhline(y=1.0, color="gray", linestyle="--", linewidth=0.8, alpha=0.7) - else: - ax.set_ylabel(metric_info["ylabel"]) - ax.set_title(f"{metric_info['title']} by Algorithm") - ax.set_xticks(x) - ax.set_xticklabels(labels, fontsize=8) - ax.legend() - ax.grid(axis="y", alpha=0.3) - - plt.tight_layout() - - if output_file: - plt.savefig(str(output_file), dpi=150, bbox_inches="tight") - logger.info("Saved to %s", output_file) - else: - plt.show() diff --git a/scripts/ckpt/cli.py b/scripts/ckpt/cli.py index 666d8d02..e948a121 100644 --- a/scripts/ckpt/cli.py +++ b/scripts/ckpt/cli.py @@ -14,7 +14,6 @@ import click -from .analysis.plot import ALGORITHMS, METRICS from .bench.all import ALL_ALGORITHMS, DEFAULT_ALGORITHMS from .compile.common import HALT_MODES from .errors import ( @@ -1815,51 +1814,6 @@ def analyze_milp_coarse_cmd( write_milp_coarse_summary_csv(summary_rows, Path(output)) -@analyze.command("plot") -@click.argument("csv_dir", type=click.Path(exists=True)) -@click.option( - "--metric", - type=click.Choice(list(METRICS.keys())), - default="prologue", - help="Metric to plot.", -) -@click.option( - "--algorithms", - multiple=True, - type=click.Choice(list(ALGORITHMS.keys())), - help="Algorithms to include (default: all).", -) -@click.option("--benchmarks", multiple=True, help="Benchmark programs to include.") -@click.option("--capacitors", multiple=True, help="Capacitor sizes to include.") -@click.option( - "--normalize", - type=click.Choice(list(ALGORITHMS.keys())), - help="Normalize values relative to this algorithm.", -) -@click.option("-o", "--output", type=click.Path(), help="Output file (e.g., plot.png).") -def analyze_plot_cmd( - csv_dir: str, - metric: str, - algorithms: tuple[str, ...], - benchmarks: tuple[str, ...], - capacitors: tuple[str, ...], - normalize: str | None, - output: str | None, -) -> None: - """Plot benchmark comparison chart from CSV results.""" - from .analysis.plot import plot_benchmarks - - plot_benchmarks( - csv_dir=Path(csv_dir), - metric=metric, - algorithms=_list_or_none(algorithms), - benchmarks=_list_or_none(benchmarks), - capacitors=_list_or_none(capacitors), - normalize=normalize, - output_file=_path_or_none(output), - ) - - # ========================================================================= # device group # ========================================================================= diff --git a/scripts/plot_common.R b/scripts/plot_common.R new file mode 100644 index 00000000..ab16981d --- /dev/null +++ b/scripts/plot_common.R @@ -0,0 +1,151 @@ +# Shared by plot_results.R and plot_intermittent.R: package setup, plot +# config parsing, ggpattern fill styles, and the two-line y-axis title. +# Callers define script_dir before sourcing this file. + +local_r_lib <- file.path(dirname(script_dir), ".Rlib") +if (dir.exists(local_r_lib)) { + .libPaths(c(local_r_lib, .libPaths())) +} + +suppressPackageStartupMessages({ + library(tidyverse) + library(scales) + library(grid) + library(gtable) +}) + +if (!requireNamespace("jsonlite", quietly = TRUE)) { + stop("jsonlite is required to read the plot config. ", + "Install it with install.packages(\"jsonlite\").") +} + + +# -- Series definitions (from --config) --------------------------------------- +# +# No CSV name is hardcoded here. The config lists two kinds of series: +# +# algorithms per-capacitor results, one bar group per capacitor plot. Files +# default to ".csv" / "_debug.csv" (with the +# "-swbor-no-debug" / "-swbor" spellings as fallbacks); override +# with explicit "csv" / "debug_csv". +# baselines capacitor-independent references (e.g. uninstrumented builds), +# replicated across benchmarks. "csv" is required. Optional +# "metrics" restricts the baseline to specific metric keys, and +# "normalize_ref" marks the series normalization divides by. +# +# Entry order defines bar and legend order; algorithms come before baselines. + +DEFAULT_CONFIG_PATH <- file.path(script_dir, "plot_config.json") + +config_field <- function(entry, name, default) { + value <- entry[[name]] + if (is.null(value)) default else value +} + +parse_series_style <- function(entry, kind) { + if (is.null(entry$algo) || is.null(entry$label)) { + stop("Each ", kind, " entry needs an \"algo\" id and a \"label\".") + } + tibble( + algo = as.character(entry$algo), + label = as.character(entry$label), + color = as.character(config_field(entry, "color", "#595959")), + pattern = as.character(config_field(entry, "pattern", "none")), + pattern_angle = as.numeric(config_field(entry, "pattern_angle", 0)) + ) +} + +load_plot_config <- function(path) { + if (!file.exists(path)) { + stop("Plot config not found: ", path) + } + raw <- jsonlite::fromJSON(path, simplifyVector = FALSE) + + if (length(raw$algorithms) == 0) { + stop("Plot config has no \"algorithms\" entries: ", path) + } + + algorithms <- map_dfr(raw$algorithms, function(entry) { + parse_series_style(entry, "algorithm") %>% + mutate( + csv = as.character(config_field(entry, "csv", NA_character_)), + debug_csv = as.character(config_field(entry, "debug_csv", NA_character_)) + ) + }) + + baselines <- map_dfr(raw$baselines, function(entry) { + if (is.null(entry$csv)) { + stop("Baseline \"", entry$algo, "\" needs a \"csv\" filename.") + } + parse_series_style(entry, "baseline") %>% + mutate( + csv = as.character(entry$csv), + metrics = list(as.character(config_field(entry, "metrics", character()))), + normalize_ref = isTRUE(config_field(entry, "normalize_ref", FALSE)) + ) + }) + + style <- bind_rows( + select(algorithms, algo, label, color, pattern, pattern_angle), + if (nrow(baselines) > 0) { + select(baselines, algo, label, color, pattern, pattern_angle) + } + ) + duplicated_ids <- unique(style$algo[duplicated(style$algo)]) + if (length(duplicated_ids) > 0) { + stop("Duplicate series ids in ", path, ": ", + paste(duplicated_ids, collapse = ", ")) + } + + norm_ref <- if (nrow(baselines) > 0) baselines$algo[baselines$normalize_ref] else character() + if (length(norm_ref) > 1) { + stop("More than one baseline is marked \"normalize_ref\" in ", path, ": ", + paste(norm_ref, collapse = ", ")) + } + + list( + algorithms = algorithms, + baselines = baselines, + style = style, + norm_ref = if (length(norm_ref) == 1) norm_ref else NA_character_ + ) +} + +# -- Fill patterns ------------------------------------------------------------- + +PATTERN_FILL_COLOUR <- "white" +PATTERN_COLOUR <- "grey10" +PATTERN_DENSITY <- 0.3 +PATTERN_SPACING <- 0.055 +PATTERN_UNITS <- "in" +PATTERN_ALPHA <- 1.0 +PATTERN_SIZE <- 0.3 +PATTERN_LEGEND_SCALE <- 1.0 +PATTERN_LEGEND_DENSITY <- 0.42 +PATTERN_LEGEND_SPACING <- 0.055 +PATTERN_LEGEND_ALPHA <- 1.0 +PATTERN_LEGEND_SIZE <- 0.42 + +# -- Axis title --------------------------------------------------------------- + +# Y-axis title with a smaller second line. Plotmath's atop() pads the two +# lines far more than the text needs, so the title grob is built directly and +# swapped into the rendered gtable. +stack_ylab <- function(p, main, sub) { + main_g <- textGrob(main, rot = 90, + gp = gpar(fontsize = 15.5, fontfamily = "Helvetica")) + sub_g <- textGrob(sub, rot = 90, + gp = gpar(fontsize = 11, fontfamily = "Helvetica")) + # Column widths are line heights rather than grobWidth(), which ignores + # descenders. + title <- gtable( + widths = unit(c(15.5 * 1.2, 11 * 1.2, 7), "pt"), + heights = unit(1, "null") + ) + title <- gtable_add_grob(title, list(main_g, sub_g), t = 1, l = c(1, 2)) + gt <- ggplotGrob(p) + idx <- which(gt$layout$name == "ylab-l") + gt$grobs[[idx]] <- title + gt$widths[gt$layout$l[idx]] <- sum(title$widths) + gt +} diff --git a/scripts/plot_config.json b/scripts/plot_config.json index 36d1c4ca..e2654a51 100644 --- a/scripts/plot_config.json +++ b/scripts/plot_config.json @@ -1,8 +1,8 @@ { "algorithms": [ { "algo": "milp", "label": "BAO", "color": "#0072B2", "pattern": "stripe", "pattern_angle": 45 }, - { "algo": "schematic", "label": "SCHEMATIC", "color": "#D55E00", "pattern": "stripe", "pattern_angle": -45 }, - { "algo": "rockclimb", "label": "ROCKCLIMB", "color": "#009E73", "pattern": "stripe", "pattern_angle": 90 }, + { "algo": "rockclimb", "label": "ROCKCLIMB", "color": "#009E73", "pattern": "crosshatch", "pattern_angle": 45 }, + { "algo": "schematic", "label": "SCHEMATIC", "color": "#D55E00", "pattern": "circle", "pattern_angle": 0 }, { "algo": "schematicO3", "label": "SCHEMATIC-O3", "color": "#CC79A7", "pattern": "stripe", "pattern_angle": 0 } ], "baselines": [ diff --git a/scripts/plot_config_o0.json b/scripts/plot_config_o0.json index 37c47a4d..1a031cb2 100644 --- a/scripts/plot_config_o0.json +++ b/scripts/plot_config_o0.json @@ -1,8 +1,8 @@ { "algorithms": [ { "algo": "milp", "label": "BAO", "color": "#0072B2", "pattern": "stripe", "pattern_angle": 45 }, - { "algo": "schematic", "label": "SCHEMATIC", "color": "#D55E00", "pattern": "stripe", "pattern_angle": -45 }, - { "algo": "rockclimb", "label": "ROCKCLIMB", "color": "#009E73", "pattern": "stripe", "pattern_angle": 90 }, + { "algo": "rockclimb", "label": "ROCKCLIMB", "color": "#009E73", "pattern": "crosshatch", "pattern_angle": 45 }, + { "algo": "schematic", "label": "SCHEMATIC", "color": "#D55E00", "pattern": "circle", "pattern_angle": 0 }, { "algo": "schematicO3", "label": "SCHEMATIC-O3", "color": "#CC79A7", "pattern": "stripe", "pattern_angle": 0 } ], "baselines": [ diff --git a/scripts/plot_intermittent.R b/scripts/plot_intermittent.R index 38cba630..8195d7bc 100644 --- a/scripts/plot_intermittent.R +++ b/scripts/plot_intermittent.R @@ -13,10 +13,14 @@ # Usage: # Rscript scripts/plot_intermittent.R [--result-dir DIR] [--output-dir DIR] -suppressPackageStartupMessages({ - library(tidyverse) - library(scales) -}) +script_args <- commandArgs(trailingOnly = FALSE) +script_file_arg <- script_args[grepl("^--file=", script_args)] +script_dir <- if (length(script_file_arg) > 0) { + dirname(normalizePath(sub("^--file=", "", script_file_arg[1]))) +} else { + "scripts" +} +source(file.path(script_dir, "plot_common.R")) args <- commandArgs(trailingOnly = TRUE) get_arg <- function(flag, default) { @@ -27,8 +31,13 @@ result_dir <- get_arg("--result-dir", "results/intermittent") output_dir <- get_arg("--output-dir", result_dir) REF <- "milp" -ALGOS <- c(rockclimb = "ROCKCLIMB", schematic = "SCHEMATIC", schematicO3 = "SCHEMATIC-O3") -COLORS <- c(ROCKCLIMB = "#009E73", SCHEMATIC = "#D55E00", `SCHEMATIC-O3` = "#CC79A7") +config <- load_plot_config(DEFAULT_CONFIG_PATH) +REF_LABEL <- config$algorithms$label[config$algorithms$algo == REF] +series <- config$algorithms %>% filter(algo != REF) +ALGOS <- setNames(series$label, series$algo) +COLORS <- setNames(series$color, series$label) +PATTERNS <- setNames(series$pattern, series$label) +PATTERN_ANGLES <- setNames(series$pattern_angle, series$label) BENCHMARK_LABELS <- c(activity_recognition = "ar", stringsearch = "string_search") theme_benchmark <- function() { @@ -41,6 +50,7 @@ theme_benchmark <- function() { legend.position = "top", legend.title = element_blank(), legend.text = element_text(size = 13), + legend.key.size = unit(0.68, "cm"), legend.margin = margin(0, 0, 0, 0), panel.grid.major.x = element_blank(), panel.grid.minor = element_blank(), @@ -93,7 +103,24 @@ box_data <- bind_rows( per_bench %>% transmute(benchmark = "geomean", algo, normalized = geomean) ) %>% mutate(benchmark = factor(benchmark, levels = levels(bars$benchmark))) p_box <- ggplot(box_data, aes(benchmark, normalized, fill = algo)) + - geom_boxplot(position = position_dodge(0.8), width = 0.7, outlier.size = 0.8, linewidth = 0.35) + + ggpattern::geom_boxplot_pattern( + aes(pattern = algo, pattern_angle = algo), + position = position_dodge(0.8), width = 0.7, outlier.size = 0.8, linewidth = 0.35, + key_glyph = ggpattern::draw_key_polygon_pattern, + pattern_fill = PATTERN_FILL_COLOUR, pattern_colour = PATTERN_COLOUR, + pattern_density = PATTERN_DENSITY, pattern_spacing = PATTERN_SPACING, + pattern_units = PATTERN_UNITS, + pattern_alpha = PATTERN_ALPHA, pattern_size = PATTERN_SIZE, + pattern_key_scale_factor = 1.0) + + ggpattern::scale_pattern_manual(values = PATTERNS) + + ggpattern::scale_pattern_angle_manual(values = PATTERN_ANGLES) + + guides(pattern = "none", pattern_angle = "none", fill = guide_legend(override.aes = list( + pattern = unname(PATTERNS), pattern_angle = unname(PATTERN_ANGLES), + pattern_fill = PATTERN_FILL_COLOUR, pattern_colour = PATTERN_COLOUR, + pattern_density = PATTERN_LEGEND_DENSITY, pattern_spacing = PATTERN_LEGEND_SPACING, + pattern_units = PATTERN_UNITS, + pattern_alpha = PATTERN_LEGEND_ALPHA, pattern_size = PATTERN_LEGEND_SIZE, + pattern_key_scale_factor = PATTERN_LEGEND_SCALE))) + geom_point(data = bars, aes(benchmark, geomean, group = algo), shape = 23, size = 1.8, fill = "white", position = position_dodge(0.8)) + geom_text(data = bars, aes(benchmark, 0.72, label = sprintf("%.1f", geomean), color = algo), @@ -103,6 +130,7 @@ p_box <- ggplot(box_data, aes(benchmark, normalized, fill = algo)) + scale_fill_manual(values = COLORS) + scale_color_manual(values = COLORS) + y_scale + - labs(y = "Execution time normalized to BAO") + theme_benchmark() -ggsave(file.path(output_dir, "normalized_time_box.pdf"), p_box, width = 12.5, height = 3.95) +ggsave(file.path(output_dir, "normalized_time_box.pdf"), + stack_ylab(p_box, "Relative Execution Time", paste0("(to ", REF_LABEL, ")")), + width = 12.5, height = 3.95) diff --git a/scripts/plot_results.R b/scripts/plot_results.R index 18fa4f49..d05a20e0 100644 --- a/scripts/plot_results.R +++ b/scripts/plot_results.R @@ -38,118 +38,13 @@ script_dir <- if (!is.na(script_path)) { } else { "scripts" } -local_r_lib <- file.path(dirname(script_dir), ".Rlib") -if (dir.exists(local_r_lib)) { - .libPaths(c(local_r_lib, .libPaths())) -} - -suppressPackageStartupMessages({ - library(tidyverse) - library(scales) - library(grid) -}) +source(file.path(script_dir, "plot_common.R")) -if (!requireNamespace("jsonlite", quietly = TRUE)) { - stop("jsonlite is required to read the plot config. ", - "Install it with install.packages(\"jsonlite\").") -} +REQUIRED_ALGO_FOR_BENCHMARKS <- NULL HAS_GGPATTERN <- requireNamespace("ggpattern", quietly = TRUE) PDF_DEVICE <- "pdf" -# -- Series definitions (from --config) --------------------------------------- -# -# No CSV name is hardcoded here. The config lists two kinds of series: -# -# algorithms per-capacitor results, one bar group per capacitor plot. Files -# default to ".csv" / "_debug.csv" (with the -# "-swbor-no-debug" / "-swbor" spellings as fallbacks); override -# with explicit "csv" / "debug_csv". -# baselines capacitor-independent references (e.g. uninstrumented builds), -# replicated across benchmarks. "csv" is required. Optional -# "metrics" restricts the baseline to specific metric keys, and -# "normalize_ref" marks the series normalization divides by. -# -# Entry order defines bar and legend order; algorithms come before baselines. - -DEFAULT_CONFIG_PATH <- file.path(script_dir, "plot_config.json") - -REQUIRED_ALGO_FOR_BENCHMARKS <- NULL - -config_field <- function(entry, name, default) { - value <- entry[[name]] - if (is.null(value)) default else value -} - -parse_series_style <- function(entry, kind) { - if (is.null(entry$algo) || is.null(entry$label)) { - stop("Each ", kind, " entry needs an \"algo\" id and a \"label\".") - } - tibble( - algo = as.character(entry$algo), - label = as.character(entry$label), - color = as.character(config_field(entry, "color", "#595959")), - pattern = as.character(config_field(entry, "pattern", "none")), - pattern_angle = as.numeric(config_field(entry, "pattern_angle", 0)) - ) -} - -load_plot_config <- function(path) { - if (!file.exists(path)) { - stop("Plot config not found: ", path) - } - raw <- jsonlite::fromJSON(path, simplifyVector = FALSE) - - if (length(raw$algorithms) == 0) { - stop("Plot config has no \"algorithms\" entries: ", path) - } - - algorithms <- map_dfr(raw$algorithms, function(entry) { - parse_series_style(entry, "algorithm") %>% - mutate( - csv = as.character(config_field(entry, "csv", NA_character_)), - debug_csv = as.character(config_field(entry, "debug_csv", NA_character_)) - ) - }) - - baselines <- map_dfr(raw$baselines, function(entry) { - if (is.null(entry$csv)) { - stop("Baseline \"", entry$algo, "\" needs a \"csv\" filename.") - } - parse_series_style(entry, "baseline") %>% - mutate( - csv = as.character(entry$csv), - metrics = list(as.character(config_field(entry, "metrics", character()))), - normalize_ref = isTRUE(config_field(entry, "normalize_ref", FALSE)) - ) - }) - - style <- bind_rows( - select(algorithms, algo, label, color, pattern, pattern_angle), - if (nrow(baselines) > 0) { - select(baselines, algo, label, color, pattern, pattern_angle) - } - ) - duplicated_ids <- unique(style$algo[duplicated(style$algo)]) - if (length(duplicated_ids) > 0) { - stop("Duplicate series ids in ", path, ": ", - paste(duplicated_ids, collapse = ", ")) - } - - norm_ref <- if (nrow(baselines) > 0) baselines$algo[baselines$normalize_ref] else character() - if (length(norm_ref) > 1) { - stop("More than one baseline is marked \"normalize_ref\" in ", path, ": ", - paste(norm_ref, collapse = ", ")) - } - - list( - algorithms = algorithms, - baselines = baselines, - style = style, - norm_ref = if (length(norm_ref) == 1) norm_ref else NA_character_ - ) -} - # -- Metric definitions ------------------------------------------------------- METRICS <- list( @@ -350,31 +245,18 @@ DEFAULT_METRICS <- c("execution_time", "runtime_region_boundary_calls") LOG_SCALE_METRICS <- c("execution_time", "runtime_region_boundary_calls") OUTLIER_RATIO_THRESHOLD <- 3.0 OUTLIER_HEADROOM <- 1.15 -LINEAR_LABEL_CROWDING_THRESHOLD <- 0.04 -TRANSFORMED_LABEL_CROWDING_THRESHOLD <- 0.11 -LINEAR_LABEL_OFFSET_BASE <- 0.055 -LINEAR_LABEL_OFFSET_STEP <- 0.08 -TRANSFORMED_LABEL_OFFSET_BASE <- 0.08 -TRANSFORMED_LABEL_OFFSET_STEP <- 0.12 -LINEAR_TOP_HEADROOM_BASE <- 0.05 -LINEAR_TOP_HEADROOM_PER_TIER <- 0.08 -TRANSFORMED_TOP_HEADROOM_BASE <- 0.04 -TRANSFORMED_TOP_HEADROOM_PER_TIER <- 0.12 -PATTERN_FILL_COLOUR <- "white" -PATTERN_COLOUR <- "grey10" -PATTERN_DENSITY <- 0.28 -PATTERN_SPACING <- 0.05 -PATTERN_ALPHA <- 1.0 -PATTERN_SIZE <- 0.3 -PATTERN_LEGEND_SCALE <- 1.4 -PATTERN_LEGEND_DENSITY <- 0.42 -PATTERN_LEGEND_SPACING <- 0.035 -PATTERN_LEGEND_ALPHA <- 1.0 -PATTERN_LEGEND_SIZE <- 0.42 - +# Bar value labels: vertical text anchored at its own bar. Gap and text +# extent are fractions of the panel height (axis space), so they look the +# same on linear, pseudo-log, and log axes. +LABEL_SIZE <- 3.6 +LABEL_GAP <- 0.015 +LABEL_EXTENT_BASE <- 0.01 +LABEL_EXTENT_PER_CHAR <- 0.028 format_metric_value <- function(value, normalize) { if (normalize) { - return(sprintf("%.2f", value)) + return(if (value >= 100) sprintf("%.0f", value) + else if (value >= 10) sprintf("%.1f", value) + else sprintf("%.2f", value)) } if (value >= 1e6) { @@ -434,160 +316,6 @@ compute_display_limit <- function(values) { max(second, tail) * OUTLIER_HEADROOM } -assign_label_tiers <- function(df, threshold) { - if (nrow(df) == 0) { - return(df) - } - - row_ids <- seq_len(nrow(df)) - ordered_idx <- order(df$transformed_value, decreasing = TRUE, na.last = TRUE) - ordered_df <- df[ordered_idx, , drop = FALSE] - ordered_rows <- row_ids[ordered_idx] - - cluster_id <- integer(nrow(ordered_df)) - current_cluster <- 1L - - for (i in seq_len(nrow(ordered_df))) { - if (i == 1) { - cluster_id[i] <- current_cluster - next - } - - prev_value <- ordered_df$transformed_value[i - 1] - curr_value <- ordered_df$transformed_value[i] - separated <- !is.finite(prev_value) || !is.finite(curr_value) || - abs(prev_value - curr_value) >= threshold - - if (separated) { - current_cluster <- current_cluster + 1L - } - cluster_id[i] <- current_cluster - } - - ordered_df <- ordered_df %>% - mutate(row_id = ordered_rows, cluster_id = cluster_id) %>% - group_by(cluster_id) %>% - mutate(label_tier = row_number() - 1L) %>% - ungroup() - - ordered_df[order(ordered_df$row_id), , drop = FALSE] %>% - select(-row_id) -} - -compute_label_positions <- function(df, force_log, use_symlog) { - if (nrow(df) == 0) { - return(df) - } - - transformed_values <- if (force_log) { - log10(df$display_value) - } else if (use_symlog) { - pseudo_log_trans(base = 10)$transform(df$display_value) - } else { - scale_ref <- max(df$display_value, na.rm = TRUE) - df$display_value / max(scale_ref, 1) - } - - threshold <- if (force_log || use_symlog) { - TRANSFORMED_LABEL_CROWDING_THRESHOLD - } else { - LINEAR_LABEL_CROWDING_THRESHOLD - } - - positioned_df <- df %>% - mutate(transformed_value = transformed_values) %>% - group_by(benchmark) %>% - group_modify(~ assign_label_tiers(.x, threshold)) %>% - ungroup() %>% - group_by(benchmark, cluster_id) %>% - mutate( - cluster_top_transformed = max(transformed_value, na.rm = TRUE), - cluster_top_display = max(display_value, na.rm = TRUE) - ) %>% - ungroup() %>% - mutate( - benchmark_index = as.integer(benchmark), - benchmark_count = max(benchmark_index, na.rm = TRUE), - label_hjust = case_when( - benchmark_index == 1L ~ 0, - benchmark_index == benchmark_count ~ 1, - TRUE ~ 0.5 - ) - ) - - if (force_log || use_symlog) { - trans <- if (force_log) { - transform_log10() - } else { - pseudo_log_trans(base = 10) - } - positioned_df <- positioned_df %>% - mutate( - label_y = trans$inverse( - cluster_top_transformed + - TRANSFORMED_LABEL_OFFSET_BASE + - TRANSFORMED_LABEL_OFFSET_STEP * label_tier - ) - ) - } else { - scale_ref <- max(positioned_df$display_value, na.rm = TRUE) - positioned_df <- positioned_df %>% - mutate( - label_y = cluster_top_display + - scale_ref * (LINEAR_LABEL_OFFSET_BASE + - LINEAR_LABEL_OFFSET_STEP * label_tier) - ) - } - - positioned_df %>% - select(-transformed_value, -cluster_id, -cluster_top_transformed, - -cluster_top_display, -benchmark_index, -benchmark_count) -} - -compute_max_label_tier <- function(df) { - if (nrow(df) == 0 || !"label_tier" %in% names(df)) { - return(0L) - } - - as.integer(max(0, max(df$label_tier, na.rm = TRUE))) -} - -extract_label_positions <- function(df) { - if (!"label_y" %in% names(df)) { - return(numeric()) - } - - df$label_y -} - -compute_upper_limit <- function(max_value, max_label_tier, force_log, use_symlog, - has_clipped_values) { - if (!is.finite(max_value) || max_value <= 0) { - return(max_value) - } - - if (force_log || use_symlog) { - trans <- if (force_log) { - transform_log10() - } else { - pseudo_log_trans(base = 10) - } - headroom <- TRANSFORMED_TOP_HEADROOM_BASE + - TRANSFORMED_TOP_HEADROOM_PER_TIER * max_label_tier - if (has_clipped_values) { - headroom <- headroom + 0.05 - } - return(trans$inverse(trans$transform(max_value) + headroom)) - } - - headroom <- LINEAR_TOP_HEADROOM_BASE + - LINEAR_TOP_HEADROOM_PER_TIER * max_label_tier - if (has_clipped_values) { - headroom <- headroom + 0.04 - } - max_value * (1 + headroom) -} - plot_metric_for_cap <- function(cap, metric_key, metric_info, algo_data, baseline_data, benchmarks, normalize, log_scale, config) { @@ -640,7 +368,8 @@ plot_metric_for_cap <- function(cap, metric_key, metric_info, left_join(base, by = "benchmark") %>% mutate(value = if_else(!is.na(base_value) & base_value != 0, value / base_value, NA_real_)) %>% - select(-base_value) + select(-base_value) %>% + filter(algo != norm_algo) } # Compute geometric mean across benchmarks for each algorithm @@ -701,8 +430,10 @@ plot_metric_for_cap <- function(cap, metric_key, metric_info, plot_df <- plot_df %>% mutate(display_value = log_t(display_value)) } + y_sub <- NULL if (normalize && !is.null(norm_algo)) { y_label <- metric_info$relative_ylabel + y_sub <- paste0("(to ", alg_style$label[alg_style$algo == norm_algo], ")") } else { y_label <- metric_info$ylabel } @@ -751,6 +482,7 @@ plot_metric_for_cap <- function(cap, metric_key, metric_info, pattern_colour = PATTERN_COLOUR, pattern_density = PATTERN_DENSITY, pattern_spacing = PATTERN_SPACING, + pattern_units = PATTERN_UNITS, pattern_alpha = PATTERN_ALPHA, pattern_size = PATTERN_SIZE, pattern_key_scale_factor = 1.0 @@ -794,6 +526,7 @@ plot_metric_for_cap <- function(cap, metric_key, metric_info, pattern_colour = PATTERN_COLOUR, pattern_density = PATTERN_LEGEND_DENSITY, pattern_spacing = PATTERN_LEGEND_SPACING, + pattern_units = PATTERN_UNITS, pattern_alpha = PATTERN_LEGEND_ALPHA, pattern_size = PATTERN_LEGEND_SIZE, pattern_key_scale_factor = PATTERN_LEGEND_SCALE @@ -806,41 +539,42 @@ plot_metric_for_cap <- function(cap, metric_key, metric_info, p <- p + theme_benchmark() - # Add value labels on bars (positions computed in data space, then - # transformed to log space for force_log plots) - label_df <- plot_df_orig %>% - filter(!is.na(display_value), display_value > 0, !clipped) - if (normalize && !is.null(norm_algo)) { - label_df <- label_df %>% filter(algo != norm_algo) + # Axis-space mapping for label placement. Geoms take pre-transformed + # values on force_log plots and data values otherwise. + to_axis <- if (force_log) { + log_t + } else if (use_symlog) { + pseudo_log_trans(base = 10)$transform + } else { + identity } + from_axis <- if (use_symlog) pseudo_log_trans(base = 10)$inverse else identity + + label_df <- plot_df_orig %>% + filter(!is.na(display_value), display_value > 0) %>% + mutate( + label = vapply(value, format_metric_value, + FUN.VALUE = character(1), normalize = normalize), + label = if_else(clipped, paste0(label, " ^"), label) + ) + bar_top <- max(to_axis(plot_df_orig$display_value), na.rm = TRUE) + label_extent <- LABEL_EXTENT_BASE + + LABEL_EXTENT_PER_CHAR * max(c(0, nchar(label_df$label))) + y_hi_axis <- bar_top / (1 - LABEL_GAP - label_extent) label_df <- label_df %>% - compute_label_positions(force_log, use_symlog) - if (force_log && nrow(label_df) > 0) { - label_df <- label_df %>% mutate(label_y = log_t(label_y)) - } + mutate(label_y = from_axis(to_axis(display_value) + LABEL_GAP * y_hi_axis)) + if (nrow(label_df) > 0) { - p <- p + geom_label( + p <- p + geom_text( data = label_df, - aes( - y = label_y, - label = vapply(value, format_metric_value, - FUN.VALUE = character(1), normalize = normalize), - hjust = label_hjust - ), + aes(y = label_y, label = label, + fontface = if_else(clipped, "bold", "plain")), position = text_position, - vjust = 0, - size = 3.8, color = "grey25", - fill = alpha("white", 0.75), - linewidth = 0, - label.padding = unit(0.12, "lines") + angle = 90, hjust = 0, vjust = 0.5, + size = LABEL_SIZE, color = "grey20" ) } - if (normalize && !is.null(norm_algo)) { - clipped_df <- clipped_df %>% filter(algo != norm_algo) - } - clipped_df <- clipped_df %>% - compute_label_positions(force_log, use_symlog) if (nrow(clipped_df) > 0) { clipped_df <- clipped_df %>% mutate( @@ -851,44 +585,20 @@ plot_metric_for_cap <- function(cap, metric_key, metric_info, clipped_df <- clipped_df %>% mutate( seg_y_start = log_t(seg_y_start), - seg_y_end = log_t(seg_y_end), - label_y = log_t(label_y) + seg_y_end = log_t(seg_y_end) ) } - } - if (nrow(clipped_df) > 0) { - p <- p + - geom_segment( - data = clipped_df, - aes(x = benchmark, xend = benchmark, - y = seg_y_start, yend = seg_y_end, - group = algo_label), - inherit.aes = FALSE, - position = text_position, - linewidth = 0.35, - color = "grey15", - arrow = arrow(type = "closed", length = unit(0.07, "in")) - ) + - geom_label( - data = clipped_df, - aes( - y = label_y, - label = paste0( - vapply(value, format_metric_value, - FUN.VALUE = character(1), normalize = normalize), - " ^" - ), - hjust = label_hjust - ), - position = text_position, - vjust = 0, - size = 3.9, - color = "grey15", - fontface = "bold", - fill = alpha("white", 0.75), - linewidth = 0, - label.padding = unit(0.12, "lines") - ) + p <- p + geom_segment( + data = clipped_df, + aes(x = benchmark, xend = benchmark, + y = seg_y_start, yend = seg_y_end, + group = algo_label), + inherit.aes = FALSE, + position = text_position, + linewidth = 0.35, + color = "grey15", + arrow = arrow(type = "closed", length = unit(0.07, "in")) + ) } # Normalization reference line @@ -897,36 +607,7 @@ plot_metric_for_cap <- function(cap, metric_key, metric_info, color = "grey50", linewidth = 0.5) } - max_label_tier <- max( - compute_max_label_tier(label_df), - compute_max_label_tier(clipped_df) - ) - top_display_value <- max( - c( - plot_df$display_value, - extract_label_positions(label_df), - extract_label_positions(clipped_df) - ), - na.rm = TRUE - ) - y_hi <- if (force_log) { - # Values are already in log10 space, so the transformed headroom of - # compute_upper_limit is applied additively here (same result). - headroom <- TRANSFORMED_TOP_HEADROOM_BASE + - TRANSFORMED_TOP_HEADROOM_PER_TIER * max_label_tier - if (nrow(clipped_df) > 0) { - headroom <- headroom + 0.05 - } - top_display_value + headroom - } else { - compute_upper_limit( - top_display_value, - max_label_tier, - force_log, - use_symlog, - nrow(clipped_df) > 0 - ) - } + y_hi <- from_axis(y_hi_axis) if (force_log) { # Log-scaled bar chart drawn in pre-transformed log10 space on a linear @@ -960,7 +641,7 @@ plot_metric_for_cap <- function(cap, metric_key, metric_info, clip = "off") } - p + if (is.null(y_sub)) p else stack_ylab(p, y_label, y_sub) } # -- Main --------------------------------------------------------------------- @@ -1113,7 +794,8 @@ main <- function() { } cat("Saved", filepath, "\n") } else { - print(p) + grid.newpage() + grid.draw(p) } } } diff --git a/tests/test_capacitor_support.py b/tests/test_capacitor_support.py index da37133a..fbaad9da 100644 --- a/tests/test_capacitor_support.py +++ b/tests/test_capacitor_support.py @@ -5,7 +5,6 @@ from pathlib import Path import pytest -from ckpt.analysis.plot import _sort_key from ckpt.analysis.strip_mining import CAPACITY_MAP, parse_strip_mining_log from ckpt.bench.config import _DEFAULT_CAPS, discover_capacitors from ckpt.env import ProjectEnv @@ -46,21 +45,3 @@ def test_parse_strip_mining_log_resolves_capacitor_capacity(tmp_path: Path) -> N assert len(runs) == 1 assert runs[0]["capacitor"] == "50uF" assert runs[0]["capacity"] == CAPACITY_MAP["50uF"] - - -def test_plot_sort_key_orders_capacitors_numerically() -> None: - labels = [ - "crc\n(100uF)", - "crc\n(50uF)", - "crc\n(10uF)", - "crc\n(5uF)", - "crc\n(1uF)", - ] - - assert sorted(labels, key=_sort_key) == [ - "crc\n(1uF)", - "crc\n(5uF)", - "crc\n(10uF)", - "crc\n(50uF)", - "crc\n(100uF)", - ] diff --git a/tests/test_plot_helpers.py b/tests/test_plot_helpers.py deleted file mode 100644 index 42af43bc..00000000 --- a/tests/test_plot_helpers.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Unit tests for ckpt.analysis.plot helpers — pure functions.""" - -from __future__ import annotations - -import pytest -from ckpt.analysis.plot import _get_value, _parse_benchmark_name - -pytestmark = pytest.mark.unit - - -# --------------------------------------------------------------------------- -# _parse_benchmark_name -# --------------------------------------------------------------------------- - - -class TestParseBenchmarkName: - @pytest.mark.parametrize( - "bench, expected", - [ - ("crc-1uF", ("crc", "1uF")), - ("nodash", ("nodash", "")), - ("activity_recognition-100uF", ("activity_recognition", "100uF")), - ("a-b-c", ("a-b", "c")), - ], - ids=["simple", "no-dash", "underscore-with-dash", "multi-dash"], - ) - def test_parse(self, bench, expected): - assert _parse_benchmark_name(bench) == expected - - -# --------------------------------------------------------------------------- -# _get_value -# --------------------------------------------------------------------------- - - -class TestGetValue: - def test_valid_float(self): - row = {"runtime_region_prologue_calls": "42.5"} - assert _get_value(row, "prologue") == 42.5 - - def test_integer_string(self): - row = {"compilation_time_ms": "500"} - assert _get_value(row, "compilation_time") == 500.0 - - def test_empty_string(self): - row = {"runtime_region_prologue_calls": ""} - assert _get_value(row, "prologue") is None - - def test_non_numeric(self): - row = {"runtime_region_prologue_calls": "N/A"} - assert _get_value(row, "prologue") is None - - def test_missing_column(self): - row = {} - assert _get_value(row, "prologue") is None