check_texhealth.py flags control characters and bare macro stubs left by a shell heredoc, a class of corruption that LaTeX compiles without complaint. It skips when main.tex is absent, as in this package. fig_sec_snr now carries an inset with the OMA-to-proposed SER ratio, since the 1 to 9 percent advantage is invisible across two decades of log axis, and save() now guards inset overlap as it guards the legend. check_consistency covers the L=8 crossover, the inset ratio span, the stated secret sizes, and the Fig. 5 curve coincidence: 29 assertions.
365 lines
14 KiB
Python
365 lines
14 KiB
Python
"""Canonical replot script for paper 11: regenerates every result figure
|
|
from ../data/*.csv and writes paper-ready PDFs to ../fig/. No experiment
|
|
is rerun. All result plots share one canvas and axes rectangle (8:6 box).
|
|
Label dictionary is fixed here and copied verbatim into tables and prose.
|
|
|
|
fig_sec_snr.pdf : legitimate and eavesdropper SER vs SNR (Fig. 2)
|
|
fig_sec_keylen.pdf : SER vs key length L (Fig. 3)
|
|
fig_sec_jam.pdf : target-user SER vs JSR, four schemes (Fig. 4)
|
|
fig_sec_sens.pdf : eavesdropper SER vs fraction of key held (Fig. 5)
|
|
fig_sec_brute.pdf : eavesdropper SER vs number of key guesses (Fig. 6)
|
|
fig_sec_kpa.pdf : eavesdropper SER vs known-plaintext frames (Fig. 7)
|
|
fig_sec_real.pdf : token error rate on real streams (Fig. 8)
|
|
|
|
Curves that coincide by construction are drawn deliberately layered: the
|
|
lower one wide and semi-transparent, the upper one narrow with open
|
|
markers, and their markers staggered to different sample points through
|
|
markevery offsets. Marker size is uniform across every figure, so the
|
|
stagger, not the size, is what keeps each legend entry visible.
|
|
"""
|
|
from __future__ import annotations
|
|
from pathlib import Path
|
|
import csv
|
|
import math
|
|
|
|
import matplotlib
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DATA = ROOT / "data"
|
|
FIG = ROOT / "fig"
|
|
FIG.mkdir(exist_ok=True)
|
|
|
|
plt.rcParams.update({
|
|
"font.family": "serif",
|
|
"font.serif": ["DejaVu Serif", "Times New Roman"],
|
|
"font.size": 9,
|
|
"axes.labelsize": 9,
|
|
"legend.fontsize": 6.6,
|
|
"xtick.labelsize": 8,
|
|
"ytick.labelsize": 8,
|
|
"axes.grid": True,
|
|
"grid.linestyle": "--",
|
|
"grid.linewidth": 0.4,
|
|
"grid.alpha": 0.6,
|
|
"lines.linewidth": 1.3,
|
|
"lines.markersize": 4.5,
|
|
"figure.figsize": (3.15, 2.36),
|
|
"pdf.fonttype": 42,
|
|
})
|
|
AXES_RECT = dict(left=0.185, right=0.965, top=0.955, bottom=0.195)
|
|
|
|
C_LEGIT = "#c0392b"
|
|
C_EVE = "#2c5fa8"
|
|
C_OMA = "#7f8c8d"
|
|
C_CH = "#95a5a6"
|
|
C_MATCH = "#8e44ad"
|
|
C_PUB = "#16a085"
|
|
|
|
# fixed label dictionary: tables and prose copy these strings verbatim
|
|
LBL = {
|
|
"legit": "Legitimate",
|
|
"oma": "OMA",
|
|
"eve_pub": "Eavesdropper, public masks",
|
|
"eve_key": "Eavesdropper, wrong key",
|
|
"chance": "Random guess",
|
|
"nojam": "No jammer",
|
|
"mask": "Keyed masking",
|
|
"perm": "Permutation key",
|
|
"pad": "Index cipher",
|
|
"insider": "Insider",
|
|
"outsider": "Outsider",
|
|
}
|
|
# deliberate-layering style for the LOWER of two coinciding curves
|
|
UNDER = dict(lw=2.6, alpha=0.85) # thick filled line, layered under
|
|
# and for the curve riding on top of it
|
|
OVER = dict(lw=1.2, mfc="none") # thin open marker, rides on top
|
|
|
|
|
|
def load(name):
|
|
with open(DATA / name) as f:
|
|
return list(csv.DictReader(f))
|
|
|
|
|
|
def col(rows, k, f=float):
|
|
return [f(r[k]) for r in rows]
|
|
|
|
|
|
def save(fig, name, insets=()):
|
|
"""Write the figure and assert that no axis label is clipped.
|
|
|
|
A long y label, or wide minor tick labels such as 6x10^-1 on a log
|
|
axis that spans less than a decade, silently pushes the label off
|
|
the canvas under the fixed axes rectangle. Reading the plotting code
|
|
cannot reveal this, so the check is made on the rendered geometry.
|
|
"""
|
|
fig.subplots_adjust(**AXES_RECT)
|
|
fig.canvas.draw()
|
|
fbox = fig.get_window_extent()
|
|
for ax in fig.axes:
|
|
for lbl in (ax.yaxis.label, ax.xaxis.label):
|
|
if not lbl.get_text():
|
|
continue
|
|
b = lbl.get_window_extent()
|
|
if (b.x0 < fbox.x0 or b.y0 < fbox.y0
|
|
or b.x1 > fbox.x1 or b.y1 > fbox.y1):
|
|
raise RuntimeError(
|
|
f"{name}: axis label '{lbl.get_text()}' is clipped "
|
|
f"(label {b} outside figure {fbox}); shorten the "
|
|
f"label or widen the margin")
|
|
# No curve may pass under the legend box. Reading the code cannot
|
|
# reveal this, so the check is made on the rendered geometry, the
|
|
# same discipline as the clipping guard above.
|
|
leg = ax.get_legend()
|
|
if leg is not None:
|
|
lb = leg.get_window_extent()
|
|
for line in ax.get_lines():
|
|
# full-span reference lines (axhline/axvline) carry axes-
|
|
# fraction endpoints [0,1]; they are not data curves and,
|
|
# spanning the whole axis, would forbid any bottom legend
|
|
xd = list(line.get_xdata())
|
|
if xd == [0, 1] or list(line.get_ydata()) == [0, 1]:
|
|
continue
|
|
xy = line.get_xydata()
|
|
if len(xy) == 0:
|
|
continue
|
|
for px, py in ax.transData.transform(xy):
|
|
if lb.x0 <= px <= lb.x1 and lb.y0 <= py <= lb.y1:
|
|
raise RuntimeError(
|
|
f"{name}: a data curve passes under the legend "
|
|
f"box; move the legend or shrink it")
|
|
for ins in insets:
|
|
ib = ins.get_window_extent()
|
|
for a in fig.axes:
|
|
if a is ins:
|
|
continue
|
|
for line in a.get_lines():
|
|
xy = line.get_xydata()
|
|
if len(xy) == 0:
|
|
continue
|
|
for px, py in a.transData.transform(xy):
|
|
if ib.x0 <= px <= ib.x1 and ib.y0 <= py <= ib.y1:
|
|
raise RuntimeError(
|
|
f"{name}: a data curve passes under the inset "
|
|
f"panel; move or shrink the inset")
|
|
fig.savefig(FIG / f"{name}.pdf")
|
|
plt.close(fig)
|
|
print("[OK]", name)
|
|
|
|
|
|
def fig_snr():
|
|
r = load("sec_snr.csv")
|
|
x = col(r, "snr_db")
|
|
fig, ax = plt.subplots()
|
|
# legitimate and OMA coincide by construction; layered deliberately
|
|
ax.semilogy(x, col(r, "legit"), color=C_LEGIT, marker="o", ls="-",
|
|
markevery=(0, 3), label=LBL["legit"], **UNDER)
|
|
ax.semilogy(x, col(r, "oma"), color=C_OMA, marker="^", ls=":",
|
|
markevery=(1, 3), label=LBL["oma"], **OVER)
|
|
ax.semilogy(x, col(r, "eve_public"), color=C_PUB, marker="v",
|
|
ls="none", markevery=(2, 3), markerfacecolor="none",
|
|
label=LBL["eve_pub"])
|
|
ax.semilogy(x, col(r, "eve_wrong"), color=C_EVE, marker="s", ls="--",
|
|
label=LBL["eve_key"])
|
|
ax.plot(x, col(r, "chance"), color=C_CH, ls="-.", lw=0.9,
|
|
label=LBL["chance"])
|
|
ax.set_xlabel("SNR (dB)")
|
|
ax.set_ylabel("SER")
|
|
ax.set_xlim(min(x), max(x))
|
|
ax.legend(loc="lower left")
|
|
|
|
# the gap is a coding gain of a few percent, invisible against two
|
|
# decades of SER, so an inset reports it as a ratio
|
|
lg, om = col(r, "legit"), col(r, "oma")
|
|
ins = ax.inset_axes([0.57, 0.58, 0.39, 0.25])
|
|
ins.plot(x, [o / l for l, o in zip(lg, om)], color=C_OMA, lw=1.0,
|
|
marker="^", ms=2.4, markevery=2)
|
|
ins.axhline(1.0, color="0.55", lw=0.6, ls="--")
|
|
ins.set_xlim(min(x), max(x))
|
|
ins.set_ylim(0.995, 1.105)
|
|
ins.set_yticks([1.00, 1.05, 1.10])
|
|
ins.set_xticks([0, 10, 20])
|
|
ins.tick_params(labelsize=5.2, length=1.8, pad=1.0)
|
|
ins.set_title("OMA / proposed SER", fontsize=5.6, pad=1.5)
|
|
save(fig, "fig_sec_snr", insets=[ins])
|
|
|
|
|
|
def fig_keylen():
|
|
"""The OMA reference is the resource-matched one of oma_ser_keylen,
|
|
which is undefined below L=16 unless 16/L is an integer; those
|
|
rows carry nan and are skipped."""
|
|
r = load("sec_keylen.csv")
|
|
x = col(r, "L", int)
|
|
fig, ax = plt.subplots()
|
|
ax.semilogy(x, col(r, "legit_ser"), color=C_LEGIT, marker="o", ls="-",
|
|
label=LBL["legit"])
|
|
op = [(l, v) for l, v in zip(x, col(r, "oma")) if not math.isnan(v)]
|
|
ax.semilogy([p[0] for p in op], [p[1] for p in op], color=C_OMA,
|
|
marker="^", ls=":", label=LBL["oma"])
|
|
ax.semilogy(x, col(r, "eve_ser"), color=C_EVE, marker="s", ls="--",
|
|
label=LBL["eve_key"])
|
|
ax.set_xlabel("Key length $L$")
|
|
ax.set_ylabel("SER")
|
|
ax.set_xscale("log", base=2)
|
|
# the curves sweep the upper-left to lower-right diagonal, leaving the
|
|
# lower-left corner empty
|
|
ax.legend(loc="lower left")
|
|
save(fig, "fig_sec_keylen")
|
|
|
|
|
|
def fig_jam():
|
|
"""Target-user SER against JSR for four schemes. A linear axis is
|
|
used because the range spans less than one decade, where a log axis
|
|
would print wide minor tick labels that crowd out the y label. The
|
|
no-jammer reference is annotated on the line rather than listed in
|
|
the legend, so the legend never covers it."""
|
|
r = load("sec_jam_cmp.csv")
|
|
x = col(r, "jsr_db")
|
|
me = max(1, len(x) // 8)
|
|
fig, ax = plt.subplots()
|
|
ax.plot(x, col(r, "matched"), color=C_MATCH, marker="P", ls="--",
|
|
markevery=me, label=LBL["mask"] + ", matched")
|
|
ax.plot(x, col(r, "oma_targeted"), color=C_PUB, marker="^", ls=":",
|
|
markevery=me, label=LBL["oma"] + ", targeted")
|
|
# the two blind curves agree to 0.002; deliberate layering
|
|
ax.plot(x, col(r, "blind"), color=C_LEGIT, marker="o", ls="-",
|
|
markevery=(0, me), label=LBL["mask"] + ", blind", **UNDER)
|
|
ax.plot(x, col(r, "perm_blind"), color=C_EVE, marker="s", ls="-.",
|
|
markevery=(me // 2, me), label=LBL["perm"] + ", blind", **OVER)
|
|
nojam = float(load("sec_jam.csv")[0]["nojam"])
|
|
ax.axhline(nojam, color=C_OMA, ls=(0, (1, 3)), lw=0.9)
|
|
ax.text(max(x) - 0.6, nojam + 0.02, LBL["nojam"], ha="right",
|
|
va="bottom", fontsize=7.4, color="#555555")
|
|
ax.set_xlabel("JSR (dB)")
|
|
ax.set_ylabel("SER")
|
|
ax.set_xlim(min(x), max(x))
|
|
ax.set_ylim(0.2, 1.02)
|
|
ax.legend(loc="center right", bbox_to_anchor=(0.985, 0.47))
|
|
save(fig, "fig_sec_jam")
|
|
|
|
|
|
def fig_sens():
|
|
"""Key sensitivity of three schemes on one axis, the fraction of the
|
|
key the attacker holds. All three ride the random-guess level over
|
|
most of the range, so the flat region is deliberately layered."""
|
|
r = load("sec_sens_cmp.csv")
|
|
x = col(r, "frac")
|
|
fig, ax = plt.subplots()
|
|
ax.plot(x, col(r, "ser_mask"), color=C_LEGIT, marker="o", ls="-",
|
|
markevery=(0, 3), label=LBL["mask"], **UNDER)
|
|
ax.plot(x, col(r, "ser_perm"), color=C_EVE, marker="s", ls="--",
|
|
markevery=(1, 3), label=LBL["perm"], **OVER)
|
|
ax.plot(x, col(r, "ser_pad"), color=C_PUB, marker="v", ls="-.",
|
|
markevery=(2, 3), lw=1.2, mfc="none", label=LBL["pad"])
|
|
chance = 1.0 - (1.0 / 16.0) ** 4
|
|
ax.axhline(chance, color=C_CH, ls=":", lw=0.9, label=LBL["chance"])
|
|
ax.set_xlabel("Fraction of the key recovered")
|
|
ax.set_ylabel("Eavesdropper SER")
|
|
ax.set_xlim(0, 1)
|
|
ax.legend(loc="lower left")
|
|
save(fig, "fig_sec_sens")
|
|
|
|
|
|
def fig_brute():
|
|
"""Brute-force search against the three keyed schemes at the same
|
|
key length, each mapped through its own sensitivity curve."""
|
|
r = load("sec_brute_cmp.csv")
|
|
x = col(r, "K")
|
|
fig, ax = plt.subplots()
|
|
ax.semilogx(x, col(r, "ser_perm"), color=C_EVE, marker="s", ls="--",
|
|
label=LBL["perm"], **UNDER)
|
|
ax.semilogx(x, col(r, "ser_pad"), color=C_PUB, marker="v", ls="-.",
|
|
label=LBL["pad"], **OVER)
|
|
ax.semilogx(x, col(r, "ser_mask"), color=C_LEGIT, marker="o", ls="-",
|
|
label=LBL["mask"])
|
|
kl = load("sec_keylen.csv")
|
|
legit = float([q for q in kl if int(q["L"]) == 16][0]["legit_ser"])
|
|
ax.axhline(legit, color=C_OMA, ls=":", lw=0.9, label=LBL["legit"])
|
|
ax.set_xlabel("Number of key guesses $K$")
|
|
ax.set_ylabel("Eavesdropper SER")
|
|
ax.set_ylim(0.2, 1.05)
|
|
ax.legend(loc="lower left")
|
|
save(fig, "fig_sec_brute")
|
|
|
|
|
|
def fig_real():
|
|
r = load("real_sec_ter.csv")
|
|
x = col(r, "snr_db")
|
|
fig, ax = plt.subplots()
|
|
# legitimate/OMA and insider/outsider coincide pairwise; layered
|
|
ax.semilogy(x, col(r, "ter_legit"), color=C_LEGIT, marker="o", ls="-",
|
|
markevery=(0, 2), label=LBL["legit"], **UNDER)
|
|
ax.semilogy(x, col(r, "ter_oma"), color=C_OMA, marker="^", ls=":",
|
|
markevery=(1, 2), label=LBL["oma"], **OVER)
|
|
ax.semilogy(x, col(r, "ter_insider"), color=C_PUB, marker="v", ls="-.",
|
|
markevery=(0, 2), lw=2.6, alpha=0.85, label=LBL["insider"])
|
|
ax.semilogy(x, col(r, "ter_eve"), color=C_EVE, marker="s", ls="--",
|
|
markevery=(1, 2), label=LBL["outsider"], **OVER)
|
|
ax.set_xlabel("SNR (dB)")
|
|
ax.set_ylabel("TER")
|
|
ax.set_xlim(min(x), max(x))
|
|
ax.legend(loc="lower left")
|
|
save(fig, "fig_sec_real")
|
|
|
|
|
|
def fig_kpa():
|
|
"""Known-plaintext recovery of the keyed masks at three collection
|
|
SNRs, with the permutation key under the same attack as the linear
|
|
comparison scheme."""
|
|
r = load("kpa.csv")
|
|
fig, ax = plt.subplots()
|
|
sty = {0.0: ("#c0392b", "o"), 10.0: ("#2c5fa8", "s"),
|
|
20.0: ("#16a085", "v")}
|
|
for snr, (c, mk) in sty.items():
|
|
rows = [row for row in r if float(row["snr_db"]) == snr]
|
|
n = [float(row["n_frames"]) for row in rows]
|
|
ser = [float(row["eve_ser"]) for row in rows]
|
|
ax.semilogx(n, ser, color=c, marker=mk, ls="-",
|
|
label=LBL["mask"] + f", {int(snr)} dB")
|
|
try:
|
|
p = load("pkpa.csv")
|
|
ax.semilogx(col(p, "n_frames"), col(p, "eve_ser"), color=C_MATCH,
|
|
marker="P", ls="--", label=LBL["perm"] + ", 20 dB")
|
|
except FileNotFoundError:
|
|
print("[skip] pkpa.csv not present yet")
|
|
# legitimate reference measured with the SAME estimator as the
|
|
# eavesdropper curves, namely the four-user average of eval_ser_sse
|
|
# at L=16, taken from sec_keylen.csv rather than from the user-1
|
|
# convention of the scheme-comparison table
|
|
kl = load("sec_keylen.csv")
|
|
legit = float([r for r in kl if int(r["L"]) == 16][0]["legit_ser"])
|
|
ax.axhline(legit, color=C_OMA, ls=":", lw=0.9, label=LBL["legit"])
|
|
ax.set_xlabel("Known-plaintext frames $N$")
|
|
ax.set_ylabel("Eavesdropper SER")
|
|
ax.set_xscale("log", base=2)
|
|
# the 0 dB curve sweeps the upper-right, so anchor the legend at the
|
|
# top edge past the steep drops, above every curve at large N
|
|
ax.set_ylim(top=1.18)
|
|
ax.legend(loc="upper right", bbox_to_anchor=(1.0, 1.04))
|
|
save(fig, "fig_sec_kpa")
|
|
|
|
|
|
def main():
|
|
fig_snr()
|
|
fig_keylen()
|
|
fig_jam()
|
|
try:
|
|
fig_sens()
|
|
fig_brute()
|
|
except FileNotFoundError:
|
|
print("[skip] attack-difficulty CSVs not present yet")
|
|
try:
|
|
fig_real()
|
|
except FileNotFoundError:
|
|
print("[skip] real-token CSV not present yet")
|
|
try:
|
|
fig_kpa()
|
|
except FileNotFoundError:
|
|
print("[skip] known-plaintext CSV not present yet")
|
|
print("[done] figures in", FIG)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|