Files
internal-docs/arxiv-style/fig-scripts/draw_synthetic_ics_optionA.py
2026-02-09 00:24:40 +08:00

273 lines
8.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Option A: "Synthetic ICS Data" mini-panel (high-level features, not packets)
What it draws (one SVG, transparent background):
- Top: 23 continuous feature curves (smooth, time-aligned)
- Bottom: discrete/categorical feature strip (colored blocks)
- One vertical dashed alignment line crossing both
- Optional shaded regime window
- Optional "real vs synthetic" ghost overlay (faint gray behind one curve)
Usage:
uv run python draw_synthetic_ics_optionA.py --out ./assets/synth_ics_optionA.svg
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
@dataclass
class Params:
seed: int = 7
seconds: float = 10.0
fs: int = 300
n_curves: int = 3 # continuous channels shown
n_bins: int = 40 # discrete blocks across x
disc_vocab: int = 8 # number of discrete categories
# Layout / style
width_in: float = 6.0
height_in: float = 2.2
curve_lw: float = 2.3
ghost_lw: float = 2.0 # "real" overlay line width
strip_height: float = 0.65 # bar height in [0,1] strip axis
strip_gap_frac: float = 0.10 # gap between blocks (fraction of block width)
# Visual cues
show_alignment_line: bool = True
align_x_frac: float = 0.58 # where to place dashed line, fraction of timeline
show_regime_window: bool = True
regime_start_frac: float = 0.30
regime_end_frac: float = 0.45
show_real_ghost: bool = True # faint gray "real" behind first synthetic curve
def _smooth(x: np.ndarray, win: int) -> np.ndarray:
win = max(3, int(win) | 1) # odd
k = np.ones(win, dtype=float)
k /= k.sum()
return np.convolve(x, k, mode="same")
def make_continuous_curves(p: Params) -> tuple[np.ndarray, np.ndarray, np.ndarray | None]:
"""
Returns:
t: (T,)
Y_syn: (n_curves, T) synthetic curves
y_real: (T,) or None optional "real" ghost curve (for one channel)
"""
rng = np.random.default_rng(p.seed)
T = int(p.seconds * p.fs)
t = np.linspace(0, p.seconds, T, endpoint=False)
Y = []
for i in range(p.n_curves):
# multi-scale smooth temporal patterns
f_slow = 0.09 + 0.03 * (i % 3)
f_mid = 0.65 + 0.18 * (i % 4)
ph = rng.uniform(0, 2 * np.pi)
y = (
0.95 * np.sin(2 * np.pi * f_slow * t + ph)
+ 0.30 * np.sin(2 * np.pi * f_mid * t + 0.7 * ph)
)
# regime-like bumps
bumps = np.zeros_like(t)
for _ in range(2):
mu = rng.uniform(0.8, p.seconds - 0.8)
sig = rng.uniform(0.35, 0.85)
bumps += np.exp(-0.5 * ((t - mu) / (sig + 1e-9)) ** 2)
y += 0.55 * bumps
# mild smooth noise
noise = _smooth(rng.normal(0, 1, size=T), win=int(p.fs * 0.04))
y += 0.10 * noise
# normalize for clean presentation
y = (y - y.mean()) / (y.std() + 1e-9)
y *= 0.42
Y.append(y)
Y_syn = np.vstack(Y)
# Optional "real" ghost: similar to first curve, but slightly different
y_real = None
if p.show_real_ghost:
base = Y_syn[0].copy()
drift = _smooth(rng.normal(0, 1, size=T), win=int(p.fs * 0.18))
drift = drift / (np.std(drift) + 1e-9)
y_real = base * 0.95 + 0.07 * drift
return t, Y_syn, y_real
def make_discrete_strip(p: Params) -> np.ndarray:
"""
Piecewise-constant categorical IDs across n_bins.
Returns:
ids: (n_bins,) in [0, disc_vocab-1]
"""
rng = np.random.default_rng(p.seed + 123)
n = p.n_bins
ids = np.zeros(n, dtype=int)
cur = rng.integers(0, p.disc_vocab)
for i in range(n):
# occasional change
if i == 0 or rng.random() < 0.28:
cur = rng.integers(0, p.disc_vocab)
ids[i] = cur
return ids
def _axes_clean(ax: plt.Axes) -> None:
"""Keep axes lines optional but remove all text/numbers (diagram-friendly)."""
ax.set_xlabel("")
ax.set_ylabel("")
ax.set_title("")
ax.set_xticks([])
ax.set_yticks([])
ax.tick_params(
axis="both",
which="both",
bottom=False,
left=False,
top=False,
right=False,
labelbottom=False,
labelleft=False,
)
def draw_optionA(out_path: Path, p: Params) -> None:
# Figure
fig = plt.figure(figsize=(p.width_in, p.height_in), dpi=200)
fig.patch.set_alpha(0.0)
# Two stacked axes (shared x)
ax_top = fig.add_axes([0.08, 0.32, 0.90, 0.62])
ax_bot = fig.add_axes([0.08, 0.12, 0.90, 0.16], sharex=ax_top)
ax_top.patch.set_alpha(0.0)
ax_bot.patch.set_alpha(0.0)
# Generate data
t, Y_syn, y_real = make_continuous_curves(p)
ids = make_discrete_strip(p)
x0, x1 = float(t[0]), float(t[-1])
span = x1 - x0
# Optional shaded regime window
if p.show_regime_window:
rs = x0 + p.regime_start_frac * span
re = x0 + p.regime_end_frac * span
ax_top.axvspan(rs, re, alpha=0.12) # default color, semi-transparent
ax_bot.axvspan(rs, re, alpha=0.12)
# Optional vertical dashed alignment line
if p.show_alignment_line:
vx = x0 + p.align_x_frac * span
ax_top.axvline(vx, linestyle="--", linewidth=1.2, alpha=0.7)
ax_bot.axvline(vx, linestyle="--", linewidth=1.2, alpha=0.7)
# Continuous curves (use fixed colors for consistency)
curve_colors = ["#1f77b4", "#ff7f0e", "#2ca02c", "#9467bd"] # blue, orange, green, purple
# Ghost "real" behind the first curve (faint gray)
if y_real is not None:
ax_top.plot(t, y_real, linewidth=p.ghost_lw, color="0.65", alpha=0.55, zorder=1)
for i in range(Y_syn.shape[0]):
ax_top.plot(
t, Y_syn[i],
linewidth=p.curve_lw,
color=curve_colors[i % len(curve_colors)],
zorder=2
)
# Set top y-limits with padding
ymin, ymax = float(Y_syn.min()), float(Y_syn.max())
ypad = 0.10 * (ymax - ymin + 1e-9)
ax_top.set_xlim(x0, x1)
ax_top.set_ylim(ymin - ypad, ymax + ypad)
# Discrete strip as colored blocks
palette = [
"#e41a1c", "#377eb8", "#4daf4a", "#984ea3",
"#ff7f00", "#ffff33", "#a65628", "#f781bf",
]
n = len(ids)
bin_w = span / n
gap = p.strip_gap_frac * bin_w
ax_bot.set_ylim(0, 1)
y = (1 - p.strip_height) / 2
for i, cat in enumerate(ids):
left = x0 + i * bin_w + gap / 2
width = bin_w - gap
ax_bot.add_patch(
Rectangle(
(left, y), width, p.strip_height,
facecolor=palette[int(cat) % len(palette)],
edgecolor="none",
)
)
# Clean axes: no ticks/labels; keep spines (axes lines) visible
_axes_clean(ax_top)
_axes_clean(ax_bot)
for ax in (ax_top, ax_bot):
for side in ("left", "bottom", "top", "right"):
ax.spines[side].set_visible(True)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(out_path, format="svg", transparent=True, bbox_inches="tight", pad_inches=0.0)
plt.close(fig)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--out", type=Path, default=Path("synth_ics_optionA.svg"))
ap.add_argument("--seed", type=int, default=7)
ap.add_argument("--seconds", type=float, default=10.0)
ap.add_argument("--fs", type=int, default=300)
ap.add_argument("--curves", type=int, default=3)
ap.add_argument("--bins", type=int, default=40)
ap.add_argument("--vocab", type=int, default=8)
ap.add_argument("--no-align", action="store_true")
ap.add_argument("--no-regime", action="store_true")
ap.add_argument("--no-ghost", action="store_true")
args = ap.parse_args()
p = Params(
seed=args.seed,
seconds=args.seconds,
fs=args.fs,
n_curves=args.curves,
n_bins=args.bins,
disc_vocab=args.vocab,
show_alignment_line=not args.no_align,
show_regime_window=not args.no_regime,
show_real_ghost=not args.no_ghost,
)
draw_optionA(args.out, p)
print(f"Wrote: {args.out}")
if __name__ == "__main__":
main()