""" CSC 482 HW5 - plot the RSA cracking times. Reads the program's own output (a header line, then "bits ms" lines) and draws bit length on the x axis and crack time in seconds on the y axis, like the assignment asks. A second plot uses a log y axis, because the time roughly doubles every bit and a straight line on a log scale is the easy way to see that. If an extra-credit file is given too, a third plot puts every point on linear axes: the 16..28 points go flat next to the 32-bit one, which is exponential growth in one picture. Usage: python make_plot.py results/average-results-16-28.txt [results/extra-credit-29-32.txt] [--runs=3] --runs only sets the legend text ("average of 3 runs"); the program's output has no run count in it. Only matplotlib and the standard library (same as HW3's make_plot.py). Written with help from Claude (Anthropic). """ import math import os import subprocess import sys import matplotlib matplotlib.use("Agg") # draw to a file, no window import matplotlib.pyplot as plt from matplotlib.ticker import FuncFormatter BLUE, ORANGE = "#2b6cb0", "#c05621" def read_results(path): """Return [(bits, ms), ...] from a "Results:"/"Average results:" file.""" rows = [] with open(path, encoding="utf-8") as f: for line in f: parts = line.split() if len(parts) == 2 and parts[0].isdigit() and parts[1].lstrip("-").isdigit(): rows.append((int(parts[0]), int(parts[1]))) return rows def cpu_name(): try: out = subprocess.run( ["powershell.exe", "-NoProfile", "-Command", "(Get-CimInstance Win32_Processor).Name.Trim()"], capture_output=True, text=True, timeout=20).stdout.strip() return out or "unknown CPU" except Exception: return "unknown CPU" def seconds_fmt(y, _pos=None): """Tick or label text for a time in seconds: 850 ms, 2.5 s, 37.8 min, 2.1 h.""" if y <= 0: return "0" if y >= 3600: return f"{y/3600:.3g} h" if y >= 60: return f"{y/60:.3g} min" if y >= 1: return f"{y:.3g} s" return f"{y*1000:.3g} ms" def log_tick_fmt(y, _pos=None): """Decade ticks on the log axis: 100 ms, 1 s, 10 s, 100 s, 1000 s, 2.8 h.""" if y >= 3600: return f"{y/3600:.3g} h" if y >= 1: return f"{y:.4g} s" return f"{y*1000:.3g} ms" def long_time(sec): """Text for an estimate: minutes, hours, days or years.""" if sec < 3600: return f"{sec/60:.1f} minutes" if sec < 86400 * 2: return f"{sec/3600:.1f} hours" if sec < 86400 * 365 * 2: return f"{sec/86400:.1f} days" return f"{sec/86400/365.25:,.0f} years" def geo_mean(values): return math.exp(sum(math.log(v) for v in values) / len(values)) def main(): if len(sys.argv) < 2: print(__doc__) sys.exit(1) runs = 3 # the program's default; --runs=N for a file made with another count paths = [a for a in sys.argv[1:] if not a.startswith("--runs=")] for arg in sys.argv[1:]: if arg.startswith("--runs="): runs = int(arg.split("=", 1)[1]) if not paths: print(__doc__) sys.exit(1) main_path = paths[0] extra_path = paths[1] if len(paths) > 1 else None out_dir = os.path.dirname(os.path.abspath(main_path)) main_rows = read_results(main_path) extra_rows = read_results(extra_path) if extra_path and os.path.exists(extra_path) else [] if not main_rows: print("no 'bits ms' rows found in", main_path) sys.exit(1) cpu = cpu_name() label_main = f"average of {runs} runs" if "verage" in open(main_path, encoding="utf-8").readline() else "one run" all_rows = main_rows + extra_rows # ---- growth table: how much longer does each extra bit take? ---- print(f"{'bits':>4} {'ms':>10} {'time':>10} {'x prev bit':>10}") prev = None ratios_main, ratios_all = [], [] for idx, (bits, ms) in enumerate(all_rows): is_extra = idx >= len(main_rows) ratio = (ms / prev) if prev else float("nan") if prev and prev >= 1000 and ms >= 1000: # only trust ratios once both times are past 1 s ratios_all.append(ratio) if not is_extra: ratios_main.append(ratio) tag = " (extra credit, one run)" if is_extra else "" print(f"{bits:>4} {ms:>10,} {seconds_fmt(ms/1000):>10} {ratio:>10.2f}{tag}") prev = ms if ratios_all: g_all = geo_mean(ratios_all) first_bits = next(b for b, ms in all_rows if ms >= 1000) print(f"\ngeometric-mean growth per bit, {first_bits}..{all_rows[-1][0]} bits (rows >= 1 s): x{g_all:.2f}") if extra_rows and ratios_main: print(f" same for the required {main_rows[0][0]}..{main_rows[-1][0]} range only: x{geo_mean(ratios_main):.2f}") print(" (a search range that doubles every bit would give x2.00)") last_bits, last_ms = all_rows[-1] print(f"\nestimates from the {last_bits}-bit time ({seconds_fmt(last_ms/1000)}):") head_g = f"at x{g_all:.2f}/bit" print(f"{'bits':>6} {head_g:>18} {'at x2.00/bit':>18}") for b in (32, 36, 40, 48, 64): if b <= last_bits: continue est = last_ms / 1000 * g_all ** (b - last_bits) est2 = last_ms / 1000 * 2.0 ** (b - last_bits) print(f"{b:>6} {long_time(est):>18} {long_time(est2):>18}") xs = [b for b, _ in main_rows] ys = [ms / 1000 for _, ms in main_rows] ex = [b for b, _ in extra_rows] ey = [ms / 1000 for _, ms in extra_rows] def add_extra(ax, log=False): """Dashed orange line from the last required point through the extra-credit points.""" if not extra_rows: return px = [xs[-1]] + ex py = [max(ys[-1], 1e-3)] + ([max(v, 1e-3) for v in ey] if log else ey) plot = ax.semilogy if log else ax.plot plot(px, py, marker="s", markevery=list(range(1, len(px))), color=ORANGE, linewidth=2, linestyle="--", label="extra credit (one run)") # ---- plot 1: linear axes, 16..28 only, as the assignment asks ---- fig, ax = plt.subplots(figsize=(9, 5.5)) ax.plot(xs, ys, marker="o", color=BLUE, linewidth=2, label=f"crack time ({label_main})") ax.set_xlabel("RSA key bit length (bits in each prime; n has twice as many)") ax.set_ylabel("time to crack (seconds)") ax.set_title(f"HW5 - Multithreaded RSA cracking, Rsa.java\n{cpu}") ax.set_xticks(xs) ax.grid(True, alpha=0.3) ax.legend() for b, s in zip(xs, ys): if s >= 1: ax.annotate(f"{s:.1f}s", (b, s), textcoords="offset points", xytext=(0, 7), ha="center", fontsize=8) fig.tight_layout() out1 = os.path.join(out_dir, "HW5-plot.png") fig.savefig(out1, dpi=150) print("\nwrote", out1) # ---- plot 2: log y axis, where "doubles every bit" shows up as a straight line ---- fig, ax = plt.subplots(figsize=(9, 5.5)) ax.semilogy(xs, [max(v, 1e-3) for v in ys], marker="o", color=BLUE, linewidth=2, label=f"crack time ({label_main})") add_extra(ax, log=True) # guide line: doubling every bit, anchored at the last required point b0, ms0 = main_rows[-1] guide_x = list(range(main_rows[0][0], all_rows[-1][0] + 1)) ax.semilogy(guide_x, [ms0 / 1000 * 2 ** (b - b0) for b in guide_x], color="gray", linestyle=":", label="doubles every bit (guide)") ax.yaxis.set_major_formatter(FuncFormatter(log_tick_fmt)) ax.set_xlabel("RSA key bit length") ax.set_ylabel("time to crack (log scale)") ax.set_title(f"HW5 - same data, log scale\n{cpu}") ax.set_xticks(guide_x) ax.grid(True, which="both", alpha=0.3) ax.legend() fig.tight_layout() out2 = os.path.join(out_dir, "HW5-plot-log.png") fig.savefig(out2, dpi=150) print("wrote", out2) # ---- plot 3 (extra credit only): every point on linear axes ---- if extra_rows: fig, ax = plt.subplots(figsize=(9, 5.5)) ax.plot(xs, ys, marker="o", color=BLUE, linewidth=2, label=f"crack time ({label_main})") add_extra(ax) ax.set_yticks(range(0, int(max(ey)) + 300, 300)) # a tick every 5 minutes ax.yaxis.set_major_formatter(FuncFormatter(seconds_fmt)) ax.set_xlabel("RSA key bit length") ax.set_ylabel("time to crack") ax.set_title(f"HW5 - extra credit, {xs[0]}..{ex[-1]} bits on linear axes\n{cpu}") ax.set_xticks(xs + ex) ax.grid(True, alpha=0.3) ax.legend(loc="upper left") for b, s in zip(xs[-1:] + ex, ys[-1:] + ey): ax.annotate(seconds_fmt(s), (b, s), textcoords="offset points", xytext=(-6, 6), ha="right", fontsize=8) fig.tight_layout() out3 = os.path.join(out_dir, "HW5-plot-extra.png") fig.savefig(out3, dpi=150) print("wrote", out3) if __name__ == "__main__": main()