{"spec_id":"stereonet-equal-area","library":"matplotlib","language":"python","code":"\"\"\" anyplot.ai\nstereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection)\nLibrary: matplotlib 3.11.0 | Python 3.13.14\nQuality: 91/100 | Updated: 2026-06-16\n\"\"\"\n\nimport os\n\nimport matplotlib.patheffects as pe\nimport matplotlib.pyplot as plt\nimport numpy as np\nfrom matplotlib.collections import LineCollection\nfrom matplotlib.colors import LinearSegmentedColormap\n\n\n# Theme-adaptive chrome (see prompts/default-style-guide.md \"Theme-adaptive Chrome\")\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — first series is brand green; geological feature types are abstract\n# so the canonical order applies (Bedding is the primary focus and keeps slot 1).\nBEDDING = \"#009E73\"  # Imprint position 1 — brand green, primary feature\nJOINT = \"#C475FD\"  # Imprint position 2 — lavender\nFAULT = \"#4467A3\"  # Imprint position 3 — blue\n# Single-polarity density → imprint_seq (brand green → blue), the only allowed sequential cmap\nimprint_seq = LinearSegmentedColormap.from_list(\"imprint_seq\", [\"#009E73\", \"#4467A3\"])\n\n# Data - structural geology field measurements (strike/dip format)\nnp.random.seed(42)\n\nbedding_strike = np.random.normal(45, 12, 30) % 360\nbedding_dip = np.clip(np.random.normal(30, 8, 30), 2, 88)\n\njoint_strike = np.random.normal(300, 10, 25) % 360\njoint_dip = np.clip(np.random.normal(72, 7, 25), 2, 88)\n\nfault_strike = np.random.normal(185, 15, 20) % 360\nfault_dip = np.clip(np.random.normal(58, 10, 20), 2, 88)\n\ndatasets = {\n    \"Bedding\": (bedding_strike, bedding_dip),\n    \"Joint\": (joint_strike, joint_dip),\n    \"Fault\": (fault_strike, fault_dip),\n}\nall_strikes = np.concatenate([bedding_strike, joint_strike, fault_strike])\nall_dips = np.concatenate([bedding_dip, joint_dip, fault_dip])\nfeature_types = [\"Bedding\"] * 30 + [\"Joint\"] * 25 + [\"Fault\"] * 20\n\n# Visual hierarchy: Bedding is the primary focus (larger markers, bolder great circles)\ncolors = {\"Bedding\": BEDDING, \"Joint\": JOINT, \"Fault\": FAULT}\nmarkers = {\"Bedding\": \"o\", \"Joint\": \"s\", \"Fault\": \"^\"}\npole_sizes = {\"Bedding\": 170, \"Joint\": 110, \"Fault\": 110}\ngc_alpha = {\"Bedding\": 0.55, \"Joint\": 0.30, \"Fault\": 0.30}\ngc_lw = {\"Bedding\": 1.6, \"Joint\": 0.9, \"Fault\": 0.9}\n\n# Poles to planes in equal-area (Schmidt) projection\npole_trend_rad = np.deg2rad((all_strikes + 90) % 360)\npole_r = np.sqrt(2) * np.sin(np.deg2rad(all_dips) / 2)\n\n# Pole unit vectors on the lower hemisphere (East, North, Down) for density estimation\npole_colat = np.deg2rad(all_dips)\npole_vx = np.sin(pole_colat) * np.sin(pole_trend_rad)\npole_vy = np.sin(pole_colat) * np.cos(pole_trend_rad)\npole_vz = np.cos(pole_colat)\n\n# Plot — square canvas (6 x 6 in @ 400 dpi = 2400 x 2400 px)\nfig = plt.figure(figsize=(6, 6), dpi=400, facecolor=PAGE_BG)\nax = fig.add_subplot(111, projection=\"polar\")\nax.set_theta_zero_location(\"N\")\nax.set_theta_direction(-1)\nax.set_facecolor(PAGE_BG)\n\n# Density contours from a spherical Gaussian kernel over the pole data\ntheta_grid = np.linspace(0, 2 * np.pi, 150)\nr_grid = np.linspace(0.02, 0.98, 75)\nTHETA, R = np.meshgrid(theta_grid, r_grid)\ncolat_grid = 2 * np.arcsin(np.clip(R / np.sqrt(2), 0, 1))\ngx = np.sin(colat_grid) * np.sin(THETA)\ngy = np.sin(colat_grid) * np.cos(THETA)\ngz = np.cos(colat_grid)\n\nsigma = 0.20\nZ = np.zeros(THETA.shape)\nfor j in range(len(all_dips)):\n    cos_dist = gx * pole_vx[j] + gy * pole_vy[j] + gz * pole_vz[j]\n    Z += np.exp(-(np.arccos(np.clip(cos_dist, -1, 1)) ** 2) / (2 * sigma**2))\n\n# Threshold the lowest band so only clustered orientations are tinted (not the whole disk)\nlevels = np.linspace(Z.max() * 0.22, Z.max(), 8)\nax.contourf(THETA, R, Z, levels=levels, cmap=imprint_seq, alpha=0.45, zorder=1)\nax.contour(THETA, R, Z, levels=levels, colors=INK_MUTED, alpha=0.30, linewidths=0.5, zorder=1)\n\n# Great circles per feature type — bold mean plane + a representative subset.\n# Vectorised inline over the small set so no helper function is needed.\nt_param = np.linspace(0, np.pi, 180)\nfor feat, (strikes, dips) in datasets.items():\n    color = colors[feat]\n    mean_strike = (\n        np.degrees(np.arctan2(np.mean(np.sin(np.deg2rad(strikes))), np.mean(np.cos(np.deg2rad(strikes))))) % 360\n    )\n    mean_dip = np.mean(dips)\n    idx = np.linspace(0, len(strikes) - 1, 4, dtype=int)\n    gc_strikes = np.concatenate([[mean_strike], strikes[idx]])\n    gc_dips = np.concatenate([[mean_dip], dips[idx]])\n\n    alpha = np.deg2rad(gc_strikes)[:, None]\n    delta = np.deg2rad(gc_dips)[:, None]\n    px = np.cos(t_param) * np.sin(alpha) + np.sin(t_param) * np.cos(alpha) * np.cos(delta)\n    py = np.cos(t_param) * np.cos(alpha) - np.sin(t_param) * np.sin(alpha) * np.cos(delta)\n    pz = np.sin(t_param) * np.sin(delta)\n    trend = np.arctan2(px, py)\n    plunge = np.arctan2(pz, np.hypot(px, py))\n    r_gc = np.sqrt(2) * np.sin((np.pi / 2 - plunge) / 2)\n\n    # Mean plane (bold), then representative planes (thin)\n    ax.plot(trend[0], r_gc[0], color=color, alpha=gc_alpha[feat] + 0.25, linewidth=gc_lw[feat] + 0.9, zorder=3)\n    segments = [np.column_stack([trend[i], r_gc[i]]) for i in range(1, len(gc_strikes))]\n    ax.add_collection(LineCollection(segments, colors=color, alpha=gc_alpha[feat], linewidths=gc_lw[feat], zorder=2))\n\n# Poles as scatter points with distinct markers per feature type\nfor feat in colors:\n    mask = np.array([ft == feat for ft in feature_types])\n    ax.scatter(\n        pole_trend_rad[mask],\n        pole_r[mask],\n        c=colors[feat],\n        s=pole_sizes[feat],\n        marker=markers[feat],\n        edgecolors=PAGE_BG,\n        linewidth=1.0,\n        label=f\"{feat} poles\",\n        zorder=5,\n        path_effects=[pe.withStroke(linewidth=2.2, foreground=PAGE_BG)],\n    )\n\n# Style — perimeter degree ticks every 10°, labels at 30° with bold cardinals\nax.set_rlim(0, 1)\nax.set_rticks([])\ntheta_ticks = np.arange(0, 360, 10)\nax.set_xticks(np.deg2rad(theta_ticks))\ntick_labels = []\nfor d in theta_ticks:\n    if d == 0:\n        tick_labels.append(\"N\")\n    elif d == 90:\n        tick_labels.append(\"E\")\n    elif d == 180:\n        tick_labels.append(\"S\")\n    elif d == 270:\n        tick_labels.append(\"W\")\n    elif d % 30 == 0:\n        tick_labels.append(f\"{d}°\")\n    else:\n        tick_labels.append(\"\")\nax.set_xticklabels(tick_labels, fontsize=9, color=INK_SOFT)\n\n# Emphasise the cardinal direction labels\nfor label in ax.get_xticklabels():\n    if label.get_text() in (\"N\", \"E\", \"S\", \"W\"):\n        label.set_fontsize(14)\n        label.set_fontweight(\"bold\")\n        label.set_color(INK)\n        label.set_path_effects([pe.withStroke(linewidth=2, foreground=PAGE_BG)])\nax.grid(True, alpha=0.15, linewidth=0.5, color=INK)\n\n# Primitive circle (the horizontal plane)\ncircle_theta = np.linspace(0, 2 * np.pi, 300)\nax.plot(circle_theta, np.ones_like(circle_theta), color=INK, linewidth=2.0, zorder=4)\n\n# Legend positioned clear of the perimeter ticks\nlegend = ax.legend(\n    loc=\"lower left\",\n    bbox_to_anchor=(-0.04, -0.06),\n    fontsize=11,\n    framealpha=0.95,\n    fancybox=True,\n    markerscale=1.1,\n    borderpad=0.8,\n)\nlegend.get_frame().set_facecolor(ELEVATED_BG)\nlegend.get_frame().set_edgecolor(INK_SOFT)\nlegend.get_frame().set_linewidth(0.8)\nfor text in legend.get_texts():\n    text.set_color(INK_SOFT)\n\nax.set_title(\n    \"stereonet-equal-area · python · matplotlib · anyplot.ai\", fontsize=15, fontweight=\"medium\", pad=16, color=INK\n)\n\n# Save — square canvas, no bbox_inches=\"tight\" (it would shave the canvas off-target)\nfig.subplots_adjust(left=0.07, right=0.93, top=0.88, bottom=0.07)\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\n"}