489 lines
20 KiB
Python
489 lines
20 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 cases (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"],
|
|
# The manuscript includes each result figure at 0.85 of a 3.455 in
|
|
# column while the canvas is 3.15 in, a printed scale of 0.933. Every
|
|
# size below is therefore pre-divided by that scale so the PRINTED
|
|
# sizes are 9 pt labels, 8 pt ticks and a 6.6 pt legend. Change the
|
|
# include width and these must change with it.
|
|
"font.size": 9.7,
|
|
"axes.labelsize": 9.7,
|
|
"legend.fontsize": 7.1,
|
|
"xtick.labelsize": 8.6,
|
|
"ytick.labelsize": 8.6,
|
|
"axes.grid": True,
|
|
"grid.linestyle": "--",
|
|
"grid.linewidth": 0.4,
|
|
"grid.alpha": 0.6,
|
|
"lines.linewidth": 1.5,
|
|
"lines.markersize": 5.2,
|
|
"figure.figsize": (3.15, 2.25), # shorter canvas: same printed width and font size, less page height
|
|
"pdf.fonttype": 42,
|
|
})
|
|
AXES_RECT = dict(left=0.205, right=0.970, top=0.955, bottom=0.215)
|
|
|
|
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 _inflate(box, fig):
|
|
"""Grow a bounding box by the marker radius plus the line width, in
|
|
pixels, so a marker whose CENTER clears the box cannot still touch
|
|
its frame."""
|
|
pad = (plt.rcParams["lines.markersize"] / 2.0
|
|
+ plt.rcParams["lines.linewidth"]) * fig.dpi / 72.0
|
|
from matplotlib.transforms import Bbox
|
|
return Bbox.from_extents(box.x0 - pad, box.y0 - pad,
|
|
box.x1 + pad, box.y1 + pad)
|
|
|
|
|
|
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:
|
|
raw = leg.get_window_extent()
|
|
if (raw.x0 < fbox.x0 or raw.y0 < fbox.y0
|
|
or raw.x1 > fbox.x1 or raw.y1 > fbox.y1):
|
|
raise RuntimeError(
|
|
f"{name}: the legend box leaves the canvas "
|
|
f"({raw} outside {fbox}); narrow or move it")
|
|
lb = _inflate(raw, fig)
|
|
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 t in ax.texts:
|
|
tb = t.get_window_extent()
|
|
if (lb.x0 < tb.x1 and tb.x0 < lb.x1
|
|
and lb.y0 < tb.y1 and tb.y0 < lb.y1):
|
|
raise RuntimeError(
|
|
f"{name}: the annotation {t.get_text()!r} sits under "
|
|
f"the legend box; move one of them")
|
|
for t in ax.texts:
|
|
tb = t.get_window_extent()
|
|
for line in ax.get_lines():
|
|
xy = line.get_xydata()
|
|
if len(xy) == 0:
|
|
continue
|
|
for px, py in ax.transData.transform(xy):
|
|
if tb.x0 <= px <= tb.x1 and tb.y0 <= py <= tb.y1:
|
|
raise RuntimeError(
|
|
f"{name}: a curve is drawn through the "
|
|
f"annotation {t.get_text()!r}; move 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)
|
|
|
|
|
|
PL_CHOSEN = [] # sizes the sweep settled on, one per figure
|
|
PL_FORCED = None # set by main() on its second pass
|
|
|
|
|
|
def main_legit(snr_db="10"):
|
|
"""The legitimate SER of the main configuration, read from the curve
|
|
the main configuration produced rather than looked up by key length."""
|
|
for r in load("sec_snr.csv"):
|
|
if float(r["snr_db"]) == float(snr_db):
|
|
return float(r["legit"])
|
|
raise KeyError("no %s dB row in sec_snr.csv" % snr_db)
|
|
|
|
|
|
def place_legend(ax, cands=("lower left", "upper left", "center left",
|
|
"center right", "lower center", "upper right",
|
|
"upper center", "center", "lower right"),
|
|
sizes=(7.1, 6.8, 6.6), ncol=1):
|
|
"""Choose the location and font size whose box the fewest curve points
|
|
fall inside, scored on rendered geometry rather than guessed from the
|
|
data. The size sweep is what makes a long label set placeable: a
|
|
five-entry legend of full scheme names has no clear corner at the
|
|
default size on every figure.
|
|
|
|
The axes rectangle is applied first, because save() enforces the same
|
|
test after applying it. Scoring the default layout and then checking
|
|
a different one is how a placement that looked clear here failed
|
|
there.
|
|
|
|
When PL_FORCED is set, only that size is tried: the driver runs every
|
|
figure once to learn the smallest size any of them needs, then reruns
|
|
them all at that one size so the legends print uniformly."""
|
|
ax.figure.subplots_adjust(**AXES_RECT)
|
|
if PL_FORCED is not None:
|
|
sizes = (PL_FORCED,)
|
|
best = None
|
|
for size in sizes:
|
|
for loc in cands:
|
|
leg = ax.legend(loc=loc, prop={"size": size}, ncol=ncol,
|
|
handlelength=1.4, columnspacing=0.9,
|
|
handletextpad=0.5, borderaxespad=0.55)
|
|
ax.figure.canvas.draw()
|
|
lb = _inflate(leg.get_window_extent(), ax.figure)
|
|
hits = 0
|
|
for line in ax.get_lines():
|
|
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:
|
|
hits += 1
|
|
for t in ax.texts:
|
|
tb = t.get_window_extent()
|
|
if (lb.x0 < tb.x1 and tb.x0 < lb.x1
|
|
and lb.y0 < tb.y1 and tb.y0 < lb.y1):
|
|
hits += 50 # an annotation hidden is worse than a
|
|
# few curve points clipped
|
|
if best is None or hits < best[2]:
|
|
best = (loc, size, hits)
|
|
if hits == 0:
|
|
ax.legend(loc=loc, prop={"size": size}, ncol=ncol,
|
|
handlelength=1.4, columnspacing=0.9,
|
|
handletextpad=0.5, borderaxespad=0.55)
|
|
PL_CHOSEN.append(size)
|
|
return best
|
|
ax.legend(loc=best[0], prop={"size": best[1]}, ncol=ncol,
|
|
handlelength=1.4, columnspacing=0.9, handletextpad=0.5,
|
|
borderaxespad=0.55)
|
|
PL_CHOSEN.append(best[1])
|
|
return best
|
|
|
|
|
|
def fig_snr():
|
|
r = load("sec_snr.csv")
|
|
x = col(r, "snr_db")
|
|
fig, ax = plt.subplots()
|
|
# legitimate and the public-mask eavesdropper coincide by
|
|
# construction (same physical layer, public masks decode alike), so
|
|
# the pair is deliberately layered; OMA is separate at this frame
|
|
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"])
|
|
# the chance level lies within 3.5e-4 of the wrong-key curve, so it is
|
|
# drawn for reference but left out of the legend, which the caption
|
|
# names instead; five long entries leave this figure no clear corner
|
|
ax.plot(x, col(r, "chance"), color=C_CH, ls="-.", lw=0.9)
|
|
ax.set_xlabel("SNR (dB)")
|
|
ax.set_ylabel("SER")
|
|
ax.set_xlim(min(x), max(x))
|
|
# most of a decade below the data leaves the lower-left genuinely
|
|
# empty, which is what gives the four-entry legend a clear berth
|
|
ax.set_ylim(bottom=2e-4)
|
|
place_legend(ax)
|
|
save(fig, "fig_sec_snr")
|
|
|
|
|
|
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_ylim(top=6.0) # headroom above the flat eavesdropper curve
|
|
ax.set_xlabel("Key length $L$")
|
|
ax.set_ylabel("SER")
|
|
ax.set_xscale("log", base=2)
|
|
place_legend(ax)
|
|
save(fig, "fig_sec_keylen")
|
|
|
|
|
|
def fig_jam():
|
|
"""Target-user SER against JSR in four cases. 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 named in the caption rather than in the
|
|
legend, which keeps the legend four rows tall."""
|
|
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"])
|
|
# the unjammed reference is named in the caption rather than in the
|
|
# legend, which keeps the folded legend two rows tall
|
|
ax.axhline(nojam, color=C_OMA, ls=(0, (1, 3)), lw=0.9)
|
|
ax.set_xlabel("JSR (dB)")
|
|
ax.set_ylabel("SER")
|
|
ax.set_xlim(min(x), max(x))
|
|
ax.set_ylim(0.0, 1.02) # keep the reference line off the spine
|
|
place_legend(ax)
|
|
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"])
|
|
# the chance level comes from the stored curve, not from a second
|
|
# copy of the configuration constants
|
|
chance = float(load("sec_snr.csv")[0]["chance"])
|
|
ax.axhline(chance, color=C_CH, ls=":", lw=0.9, label=LBL["chance"])
|
|
# the narration reads these curves against the legitimate rate
|
|
ax.axhline(main_legit(), color=C_OMA, ls=(0, (4, 2)), lw=0.9,
|
|
label=LBL["legit"])
|
|
ax.set_xlabel("Fraction of the key recovered")
|
|
ax.set_ylabel("Eavesdropper SER")
|
|
ax.set_xlim(0, 1)
|
|
place_legend(ax)
|
|
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"])
|
|
legit = main_legit()
|
|
ax.axhline(legit, color=C_OMA, ls=(0, (4, 2)), lw=0.9,
|
|
label=LBL["legit"])
|
|
ax.set_xlabel("Number of key guesses $K$")
|
|
ax.set_ylabel("Eavesdropper SER")
|
|
ax.set_ylim(0.0, 1.05) # keep the reference line off the spine
|
|
place_legend(ax)
|
|
save(fig, "fig_sec_brute")
|
|
|
|
|
|
def fig_real():
|
|
r = load("real_sec_ter.csv")
|
|
x = col(r, "snr_db")
|
|
fig, ax = plt.subplots()
|
|
# insider and outsider still nearly coincide and are layered; the
|
|
# legitimate and OMA curves are separate at this frame
|
|
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), label=LBL["insider"], **UNDER)
|
|
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))
|
|
place_legend(ax)
|
|
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: (C_LEGIT, "o"), 10.0: (C_EVE, "s"),
|
|
20.0: (C_PUB, "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
|
|
# in the main configuration, rather than the user-1 convention of the
|
|
# scheme-comparison table
|
|
legit = main_legit()
|
|
ax.axhline(legit, color=C_OMA, ls=(0, (4, 2)), 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)
|
|
place_legend(ax)
|
|
save(fig, "fig_sec_kpa")
|
|
|
|
|
|
def run_all():
|
|
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")
|
|
|
|
|
|
def main():
|
|
"""Two passes: the first learns the smallest legend size any figure
|
|
needs, the second forces that one size everywhere so the legends
|
|
print uniformly, which the figure standard requires."""
|
|
global PL_FORCED
|
|
PL_FORCED = None
|
|
PL_CHOSEN.clear()
|
|
run_all()
|
|
if PL_CHOSEN:
|
|
PL_FORCED = min(PL_CHOSEN)
|
|
print("[uniform] legend size %.1f pt on every figure" % PL_FORCED)
|
|
PL_CHOSEN.clear()
|
|
run_all()
|
|
print("[done] figures in", FIG)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|