#!/usr/bin/env python3 """Build the IGF illustration figure (fig_igf.pdf). Implements the paper's equations verbatim on the synthetic site of sitegeom.py: eq:compact_kernel K(d; rho, m) = (1 - d/rho)^m for d < rho, else 0 eq:source_field U_S(z) = alpha_S * sum_{xi in S} K(|z-xi|; rho_S, m_S) ds_xi eq:field_park U_park = U_e - U_p + U_x eq:field_exit U_exit = U_e + U_p - U_x eq:field_bounds U_g^max = 2 alpha_g rho_g / (m_g + 1) U_e^max = alpha_e (2 rho_e / lbar_e + 1) eq:field_normalize Uhat = (U + U_g^max) / (U_e^max + 2 U_g^max), clipped [0,1] For S_e the paper takes ds_xi == 1 (vertex counting); S_p and S_x are equal-arc-length samples carrying their true line element. Both are reproduced. Construction illustration on a synthetic site -- not an experimental result. """ import numpy as np import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.patches import Polygon as MplPolygon from matplotlib.path import Path import sitegeom as sg # Field parameters; alpha_e is set so that eq:alpha_dominance holds. RHO_E, M_E, ALPHA_E = 8.0, 3.0, 1.00 RHO_G, M_G, ALPHA_G = 9.0, 3.0, 0.28 LBAR_E = 1.0 # mean boundary-vertex spacing, enters U_e^max DS_G = 0.5 # arc-length element of the guidance-line samples DELTA = 0.25 # field grid resolution def kernel(d, rho, m): out = np.zeros_like(d) ins = d < rho out[ins] = (1.0 - d[ins] / rho) ** m return out def source_field(Z, S, alpha, rho, m, ds): U = np.zeros(Z.shape[0]) for i in range(0, S.shape[0], 256): blk = S[i:i + 256] d = np.linalg.norm(Z[:, None, :] - blk[None, :, :], axis=2) U += (kernel(d, rho, m) * ds).sum(axis=1) return alpha * U def resample_closed(poly, step): pts = np.vstack([poly, poly[:1]]) seg = np.diff(pts, axis=0) L = np.hypot(seg[:, 0], seg[:, 1]) cum = np.concatenate([[0.0], np.cumsum(L)]) s = np.arange(0.0, cum[-1], step) out = np.empty((s.size, 2)) for i, si in enumerate(s): k = min(np.searchsorted(cum, si, side="right") - 1, len(seg) - 1) t = (si - cum[k]) / L[k] out[i] = pts[k] + t * seg[k] return out def main(): g = sg.build_centerline() center, park_line, exit_line = g["center"], g["park_line"], g["exit_line"] # ---- source sets ------------------------------------------------------- S_e = resample_closed(sg.SITE, LBAR_E) S_p = sg.resample(park_line, DS_G) S_x = sg.resample(exit_line, DS_G) # ---- grid -------------------------------------------------------------- x0, x1 = sg.SITE[:, 0].min() - 1.0, sg.SITE[:, 0].max() + 1.0 y0, y1 = sg.SITE[:, 1].min() - 1.0, sg.SITE[:, 1].max() + 1.0 xs = np.arange(x0, x1, DELTA) ys = np.arange(y0, y1, DELTA) XX, YY = np.meshgrid(xs, ys) Z = np.column_stack([XX.ravel(), YY.ravel()]) U_e = source_field(Z, S_e, ALPHA_E, RHO_E, M_E, 1.0) U_p = source_field(Z, S_p, ALPHA_G, RHO_G, M_G, DS_G) U_x = source_field(Z, S_x, ALPHA_G, RHO_G, M_G, DS_G) Ug_max = 2.0 * ALPHA_G * RHO_G / (M_G + 1.0) Ue_max = ALPHA_E * (2.0 * RHO_E / LBAR_E + 1.0) assert Ue_max > Ug_max, "eq:alpha_dominance violated" def norm(U): return np.clip((U + Ug_max) / (Ue_max + 2.0 * Ug_max), 0.0, 1.0) Up_raw, Ux_raw = U_e - U_p + U_x, U_e + U_p - U_x U_park = norm(Up_raw).reshape(XX.shape) U_exit = norm(Ux_raw).reshape(XX.shape) inside = Path(sg.SITE).contains_points(Z).reshape(XX.shape) U_parkm = np.ma.array(U_park, mask=~inside) U_exitm = np.ma.array(U_exit, mask=~inside) # ---- numeric checks reported to stdout --------------------------------- print("U_g^max = %.4f U_e^max = %.4f" % (Ug_max, Ue_max)) print("Uhat_park in [%.4f, %.4f]" % (U_parkm.min(), U_parkm.max())) print("Uhat_exit in [%.4f, %.4f]" % (U_exitm.min(), U_exitm.max())) print("mirror residual |(U_park+U_exit) - 2 U_e| = %.2e" % np.abs(Up_raw + Ux_raw - 2 * U_e).max()) # the park field must be lower on the park line than on the exit line def sample(F, P): ii = np.clip(((P[:, 0] - x0) / DELTA).astype(int), 0, len(xs) - 1) jj = np.clip(((P[:, 1] - y0) / DELTA).astype(int), 0, len(ys) - 1) return F[jj, ii] print("mean Uhat_park on Lambda_park = %.4f, on Lambda_exit = %.4f" % (sample(U_park, park_line).mean(), sample(U_park, exit_line).mean())) print("mean Uhat_exit on Lambda_park = %.4f, on Lambda_exit = %.4f" % (sample(U_exit, park_line).mean(), sample(U_exit, exit_line).mean())) # A common display ceiling for both panels: the analytic bound U_e^max is a # worst-case estimate, so the realized field only reaches ~0.34. Both # panels share one scale, which is what makes the mirror relation visible; # the colorbar is annotated with the true values, not rescaled to [0,1]. vmax = float(max(U_parkm.max(), U_exitm.max())) # ---- plot -------------------------------------------------------------- plt.rcParams.update({ "font.family": "serif", "font.serif": ["Nimbus Roman", "Times New Roman", "DejaVu Serif"], "font.size": 7, "mathtext.fontset": "stix", "axes.linewidth": 0.5, "pdf.fonttype": 42, }) fig, axes = plt.subplots(1, 3, figsize=(7.16, 1.75), constrained_layout=True) ax = axes[0] ax.add_patch(MplPolygon(sg.SITE, closed=True, facecolor="white", edgecolor="#585858", lw=0.7, zorder=1)) ax.plot(center[:, 0], center[:, 1], color="#703A94", lw=0.9, ls=(0, (2.2, 1.2)), label=r"$\Lambda_{\mathrm{center}}$", zorder=3) ax.plot(park_line[:, 0], park_line[:, 1], color="#00549F", lw=1.0, label=r"$\Lambda_{\mathrm{park}}$", zorder=3) ax.plot(exit_line[:, 0], exit_line[:, 1], color="#C44E0A", lw=1.0, label=r"$\Lambda_{\mathrm{exit}}$", zorder=3) ax.plot(*sg.LOAD_POSE, marker="o", ms=2.8, color="#222222", zorder=4) ax.annotate(r"$\boldsymbol{\eta}_{\mathrm{load}}$", sg.LOAD_POSE, textcoords="offset points", xytext=(2.5, 2.5), fontsize=6) ax.legend(loc="lower right", fontsize=5.2, frameon=False, handlelength=1.4, borderaxespad=0.1, labelspacing=0.2) ax.set_title(r"(a) boundary and guidance sources", fontsize=6.6, pad=2) for ax, U, name in ((axes[1], U_parkm, r"(b) $\hat U_{\mathrm{park}}$"), (axes[2], U_exitm, r"(c) $\hat U_{\mathrm{exit}}$")): im = ax.pcolormesh(XX, YY, U, cmap="viridis", vmin=0.0, vmax=vmax, shading="auto", rasterized=True) ax.contour(XX, YY, U.filled(np.nan), levels=np.linspace(0.06, vmax * 0.92, 5), colors="white", linewidths=0.25, alpha=0.7) ax.add_patch(MplPolygon(sg.SITE, closed=True, facecolor="none", edgecolor="#585858", lw=0.7)) ax.set_title(name, fontsize=6.6, pad=2) cb = fig.colorbar(im, ax=axes[2], fraction=0.046, pad=0.02) cb.set_ticks([0.0, vmax / 2.0, vmax]) cb.set_ticklabels(["0", "%.2f" % (vmax / 2.0), "%.2f" % vmax]) cb.ax.tick_params(labelsize=5.4, width=0.4, length=1.8) cb.outline.set_linewidth(0.4) for ax in axes: ax.set_aspect("equal") ax.set_xlim(x0, x1) ax.set_ylim(y0, y1) ax.set_xticks([]) ax.set_yticks([]) for sp in ax.spines.values(): sp.set_visible(False) fig.savefig("fig_igf.pdf", dpi=600) print("wrote fig_igf.pdf") if __name__ == "__main__": main()