|
| 1 | +""" pyplots.ai |
| 2 | +stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection) |
| 3 | +Library: matplotlib 3.10.8 | Python 3.14.3 |
| 4 | +Quality: 90/100 | Created: 2026-03-15 |
| 5 | +""" |
| 6 | + |
| 7 | +import matplotlib.patheffects as pe |
| 8 | +import matplotlib.pyplot as plt |
| 9 | +import numpy as np |
| 10 | +from matplotlib.collections import LineCollection |
| 11 | + |
| 12 | + |
| 13 | +# Data - structural geology field measurements (strike/dip format) |
| 14 | +np.random.seed(42) |
| 15 | + |
| 16 | +bedding_strike = np.random.normal(45, 12, 30) % 360 |
| 17 | +bedding_dip = np.clip(np.random.normal(30, 8, 30), 2, 88) |
| 18 | + |
| 19 | +joint_strike = np.random.normal(300, 10, 25) % 360 |
| 20 | +joint_dip = np.clip(np.random.normal(72, 7, 25), 2, 88) |
| 21 | + |
| 22 | +fault_strike = np.random.normal(185, 15, 20) % 360 |
| 23 | +fault_dip = np.clip(np.random.normal(58, 10, 20), 2, 88) |
| 24 | + |
| 25 | +datasets = { |
| 26 | + "Bedding": (bedding_strike, bedding_dip), |
| 27 | + "Joint": (joint_strike, joint_dip), |
| 28 | + "Fault": (fault_strike, fault_dip), |
| 29 | +} |
| 30 | +all_strikes = np.concatenate([bedding_strike, joint_strike, fault_strike]) |
| 31 | +all_dips = np.concatenate([bedding_dip, joint_dip, fault_dip]) |
| 32 | +feature_types = ["Bedding"] * 30 + ["Joint"] * 25 + ["Fault"] * 20 |
| 33 | + |
| 34 | +# Visual hierarchy: Bedding is primary focus (thicker, more opaque) |
| 35 | +colors = {"Bedding": "#306998", "Joint": "#E8833A", "Fault": "#8B2252"} |
| 36 | +markers = {"Bedding": "o", "Joint": "s", "Fault": "^"} |
| 37 | +pole_sizes = {"Bedding": 200, "Joint": 140, "Fault": 140} |
| 38 | +gc_alpha = {"Bedding": 0.55, "Joint": 0.25, "Fault": 0.25} |
| 39 | +gc_lw = {"Bedding": 1.8, "Joint": 0.9, "Fault": 0.9} |
| 40 | + |
| 41 | +# Pole orientations in equal-area (Schmidt) projection |
| 42 | +pole_trend_rad = np.deg2rad((all_strikes + 90) % 360) |
| 43 | +pole_r = np.sqrt(2) * np.sin(np.deg2rad(all_dips) / 2) |
| 44 | + |
| 45 | +# Pole unit vectors on sphere (East, North, Down coordinates) |
| 46 | +pole_colat = np.deg2rad(all_dips) |
| 47 | +pole_vx = np.sin(pole_colat) * np.sin(pole_trend_rad) |
| 48 | +pole_vy = np.sin(pole_colat) * np.cos(pole_trend_rad) |
| 49 | +pole_vz = np.cos(pole_colat) |
| 50 | + |
| 51 | +# Plot |
| 52 | +fig = plt.figure(figsize=(12, 12), facecolor="white") |
| 53 | +ax = fig.add_subplot(111, projection="polar") |
| 54 | +ax.set_theta_zero_location("N") |
| 55 | +ax.set_theta_direction(-1) |
| 56 | +ax.set_facecolor("#FAFAF8") |
| 57 | + |
| 58 | +# Density contours using spherical Gaussian kernel on pole data |
| 59 | +theta_grid = np.linspace(0, 2 * np.pi, 150) |
| 60 | +r_grid = np.linspace(0.02, 0.98, 75) |
| 61 | +THETA, R = np.meshgrid(theta_grid, r_grid) |
| 62 | +colat_grid = 2 * np.arcsin(np.clip(R / np.sqrt(2), 0, 1)) |
| 63 | +gx = np.sin(colat_grid) * np.sin(THETA) |
| 64 | +gy = np.sin(colat_grid) * np.cos(THETA) |
| 65 | +gz = np.cos(colat_grid) |
| 66 | + |
| 67 | +sigma = 0.20 |
| 68 | +Z = np.zeros(THETA.shape) |
| 69 | +for j in range(len(all_dips)): |
| 70 | + cos_dist = gx * pole_vx[j] + gy * pole_vy[j] + gz * pole_vz[j] |
| 71 | + Z += np.exp(-(np.arccos(np.clip(cos_dist, -1, 1)) ** 2) / (2 * sigma**2)) |
| 72 | + |
| 73 | +contour_fill = ax.contourf(THETA, R, Z, levels=10, cmap="YlOrBr", alpha=0.35, zorder=1) |
| 74 | +ax.contour(THETA, R, Z, levels=10, colors="#B87333", alpha=0.35, linewidths=0.5, zorder=1) |
| 75 | + |
| 76 | +# Great circles - representative subset only (5 per group to reduce clutter) |
| 77 | +t_param = np.linspace(0, np.pi, 180) |
| 78 | + |
| 79 | + |
| 80 | +def draw_great_circle(strike_deg, dip_deg): |
| 81 | + alpha = np.deg2rad(strike_deg) |
| 82 | + delta = np.deg2rad(dip_deg) |
| 83 | + px = np.cos(t_param) * np.sin(alpha) + np.sin(t_param) * np.cos(alpha) * np.cos(delta) |
| 84 | + py = np.cos(t_param) * np.cos(alpha) - np.sin(t_param) * np.sin(alpha) * np.cos(delta) |
| 85 | + pz = np.sin(t_param) * np.sin(delta) |
| 86 | + trend = np.arctan2(px, py) |
| 87 | + horiz = np.sqrt(px**2 + py**2) |
| 88 | + plunge = np.arctan2(pz, horiz) |
| 89 | + colat = np.pi / 2 - plunge |
| 90 | + r = np.sqrt(2) * np.sin(colat / 2) |
| 91 | + return trend, r |
| 92 | + |
| 93 | + |
| 94 | +for feat, (strikes, dips) in datasets.items(): |
| 95 | + # Mean great circle (bold) |
| 96 | + mean_strike = ( |
| 97 | + np.degrees(np.arctan2(np.mean(np.sin(np.deg2rad(strikes))), np.mean(np.cos(np.deg2rad(strikes))))) % 360 |
| 98 | + ) |
| 99 | + mean_dip = np.mean(dips) |
| 100 | + trend, r = draw_great_circle(mean_strike, mean_dip) |
| 101 | + ax.plot(trend, r, color=colors[feat], alpha=gc_alpha[feat] + 0.2, linewidth=gc_lw[feat] + 0.8, zorder=2) |
| 102 | + |
| 103 | + # 4 representative great circles (evenly spaced indices) |
| 104 | + indices = np.linspace(0, len(strikes) - 1, 4, dtype=int) |
| 105 | + segments = [] |
| 106 | + for idx in indices: |
| 107 | + trend, r = draw_great_circle(strikes[idx], dips[idx]) |
| 108 | + points = np.column_stack([trend, r]) |
| 109 | + segments.append(points) |
| 110 | + lc = LineCollection(segments, colors=colors[feat], alpha=gc_alpha[feat], linewidths=gc_lw[feat], zorder=2) |
| 111 | + ax.add_collection(lc) |
| 112 | + |
| 113 | +# Poles as scatter points with distinct markers per feature type |
| 114 | +for feat in colors: |
| 115 | + mask = np.array([ft == feat for ft in feature_types]) |
| 116 | + ax.scatter( |
| 117 | + pole_trend_rad[mask], |
| 118 | + pole_r[mask], |
| 119 | + c=colors[feat], |
| 120 | + s=pole_sizes[feat], |
| 121 | + marker=markers[feat], |
| 122 | + edgecolors="white", |
| 123 | + linewidth=1.2, |
| 124 | + label=f"{feat} poles", |
| 125 | + zorder=5, |
| 126 | + path_effects=[pe.withStroke(linewidth=2.5, foreground="white")], |
| 127 | + ) |
| 128 | + |
| 129 | +# Style |
| 130 | +ax.set_rlim(0, 1) |
| 131 | +ax.set_rticks([]) |
| 132 | +theta_ticks = np.arange(0, 360, 10) |
| 133 | +ax.set_xticks(np.deg2rad(theta_ticks)) |
| 134 | +tick_labels = [] |
| 135 | +for d in theta_ticks: |
| 136 | + if d == 0: |
| 137 | + tick_labels.append("N") |
| 138 | + elif d == 90: |
| 139 | + tick_labels.append("E") |
| 140 | + elif d == 180: |
| 141 | + tick_labels.append("S") |
| 142 | + elif d == 270: |
| 143 | + tick_labels.append("W") |
| 144 | + elif d % 30 == 0: |
| 145 | + tick_labels.append(f"{d}°") |
| 146 | + else: |
| 147 | + tick_labels.append("") |
| 148 | +ax.set_xticklabels(tick_labels, fontsize=16, fontweight="medium") |
| 149 | + |
| 150 | +# Bold cardinal direction labels |
| 151 | +for label in ax.get_xticklabels(): |
| 152 | + txt = label.get_text() |
| 153 | + if txt in ("N", "E", "S", "W"): |
| 154 | + label.set_fontsize(20) |
| 155 | + label.set_fontweight("bold") |
| 156 | + label.set_color("#222222") |
| 157 | + label.set_path_effects([pe.withStroke(linewidth=2, foreground="white")]) |
| 158 | +ax.grid(True, alpha=0.12, linewidth=0.4, color="#888888") |
| 159 | + |
| 160 | +# Primitive circle emphasis |
| 161 | +circle_theta = np.linspace(0, 2 * np.pi, 300) |
| 162 | +ax.plot(circle_theta, np.ones_like(circle_theta), color="#333333", linewidth=2.0, zorder=3) |
| 163 | + |
| 164 | +# Legend positioned to avoid tick mark overlap |
| 165 | +legend = ax.legend( |
| 166 | + loc="lower left", |
| 167 | + bbox_to_anchor=(-0.02, -0.08), |
| 168 | + fontsize=16, |
| 169 | + framealpha=0.95, |
| 170 | + edgecolor="#bbbbbb", |
| 171 | + fancybox=True, |
| 172 | + markerscale=1.3, |
| 173 | + shadow=True, |
| 174 | + borderpad=0.8, |
| 175 | +) |
| 176 | +legend.get_frame().set_linewidth(0.8) |
| 177 | + |
| 178 | +ax.set_title( |
| 179 | + "stereonet-equal-area · matplotlib · pyplots.ai", fontsize=24, fontweight="medium", pad=28, color="#333333" |
| 180 | +) |
| 181 | + |
| 182 | +plt.tight_layout() |
| 183 | +plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="white") |
0 commit comments