|
| 1 | +""" pyplots.ai |
| 2 | +stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection) |
| 3 | +Library: letsplot 4.9.0 | Python 3.14.3 |
| 4 | +Quality: 88/100 | Created: 2026-03-15 |
| 5 | +""" |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +import pandas as pd |
| 9 | +from lets_plot import ( |
| 10 | + LetsPlot, |
| 11 | + aes, |
| 12 | + coord_fixed, |
| 13 | + element_blank, |
| 14 | + element_rect, |
| 15 | + element_text, |
| 16 | + geom_density2d, |
| 17 | + geom_path, |
| 18 | + geom_point, |
| 19 | + geom_polygon, |
| 20 | + geom_segment, |
| 21 | + geom_text, |
| 22 | + ggplot, |
| 23 | + ggsize, |
| 24 | + labs, |
| 25 | + layer_tooltips, |
| 26 | + scale_color_manual, |
| 27 | + scale_fill_manual, |
| 28 | + scale_x_continuous, |
| 29 | + scale_y_continuous, |
| 30 | + theme, |
| 31 | +) |
| 32 | +from lets_plot.export import ggsave |
| 33 | + |
| 34 | + |
| 35 | +LetsPlot.setup_html() |
| 36 | + |
| 37 | +# Data - Field measurements from a structural geology mapping campaign |
| 38 | +np.random.seed(42) |
| 39 | + |
| 40 | +# Bedding planes (NE strike ~045, moderate dip ~35° SE) |
| 41 | +n_bedding = 25 |
| 42 | +bedding_strike = np.random.normal(45, 12, n_bedding) % 360 |
| 43 | +bedding_dip = np.clip(np.random.normal(35, 8, n_bedding), 5, 85) |
| 44 | + |
| 45 | +# Joint set (N-S striking ~000, steep dip ~78° W) |
| 46 | +n_joints = 20 |
| 47 | +joints_strike = np.random.normal(355, 10, n_joints) % 360 |
| 48 | +joints_dip = np.clip(np.random.normal(78, 7, n_joints), 10, 89) |
| 49 | + |
| 50 | +# Faults (NW-SE striking ~150, moderate-steep dip ~60°) |
| 51 | +n_faults = 13 |
| 52 | +faults_strike = np.random.uniform(130, 170, n_faults) |
| 53 | +faults_dip = np.clip(np.random.normal(60, 12, n_faults), 10, 89) |
| 54 | + |
| 55 | +# Foliation (E-W strike ~270, gentle dip ~25° N) |
| 56 | +n_foliation = 12 |
| 57 | +foliation_strike = np.random.normal(270, 8, n_foliation) % 360 |
| 58 | +foliation_dip = np.clip(np.random.normal(25, 6, n_foliation), 5, 60) |
| 59 | + |
| 60 | +strikes = np.concatenate([bedding_strike, joints_strike, faults_strike, foliation_strike]) |
| 61 | +dips = np.concatenate([bedding_dip, joints_dip, faults_dip, foliation_dip]) |
| 62 | +feature_types = ["Bedding"] * n_bedding + ["Joint"] * n_joints + ["Fault"] * n_faults + ["Foliation"] * n_foliation |
| 63 | + |
| 64 | +# Equal-area (Schmidt net) lower-hemisphere projection |
| 65 | +strike_rad = np.radians(strikes) |
| 66 | +dip_rad = np.radians(dips) |
| 67 | +dip_dir_rad = strike_rad + np.pi / 2 |
| 68 | + |
| 69 | +# Pole 3D coordinates (lower hemisphere normal to each plane) |
| 70 | +pole_x_3d = np.sin(dip_rad) * np.sin(dip_dir_rad) |
| 71 | +pole_y_3d = np.sin(dip_rad) * np.cos(dip_dir_rad) |
| 72 | +pole_z_3d = -np.cos(dip_rad) |
| 73 | + |
| 74 | +# Lambert azimuthal equal-area projection |
| 75 | +pole_scale = 1.0 / np.sqrt(1.0 - pole_z_3d) |
| 76 | +pole_x = pole_x_3d * pole_scale |
| 77 | +pole_y = pole_y_3d * pole_scale |
| 78 | + |
| 79 | +poles_df = pd.DataFrame( |
| 80 | + { |
| 81 | + "x": pole_x, |
| 82 | + "y": pole_y, |
| 83 | + "feature_type": feature_types, |
| 84 | + "strike": [f"{s:.0f}°" for s in strikes], |
| 85 | + "dip": [f"{d:.0f}°" for d in dips], |
| 86 | + } |
| 87 | +) |
| 88 | + |
| 89 | +# Great circle paths for each plane |
| 90 | +gc_rows = [] |
| 91 | +alphas = np.linspace(0, np.pi, 60) |
| 92 | +cos_a = np.cos(alphas) |
| 93 | +sin_a = np.sin(alphas) |
| 94 | + |
| 95 | +for i in range(len(strikes)): |
| 96 | + s = strike_rad[i] |
| 97 | + d = dip_rad[i] |
| 98 | + dd = dip_dir_rad[i] |
| 99 | + sv_x, sv_y = np.sin(s), np.cos(s) |
| 100 | + dv_x = np.cos(d) * np.sin(dd) |
| 101 | + dv_y = np.cos(d) * np.cos(dd) |
| 102 | + dv_z = -np.sin(d) |
| 103 | + px = cos_a * sv_x + sin_a * dv_x |
| 104 | + py = cos_a * sv_y + sin_a * dv_y |
| 105 | + pz = sin_a * dv_z |
| 106 | + sc = 1.0 / np.sqrt(1.0 - pz) |
| 107 | + gx = px * sc |
| 108 | + gy = py * sc |
| 109 | + # Clip to unit circle boundary |
| 110 | + r2 = gx**2 + gy**2 |
| 111 | + mask_inside = r2 <= 1.0 |
| 112 | + gx_clip = gx[mask_inside] |
| 113 | + gy_clip = gy[mask_inside] |
| 114 | + for j in range(len(gx_clip)): |
| 115 | + gc_rows.append({"x": gx_clip[j], "y": gy_clip[j], "group": i, "feature_type": feature_types[i]}) |
| 116 | + |
| 117 | +gc_df = pd.DataFrame(gc_rows) |
| 118 | + |
| 119 | +# Primitive circle (outer boundary) |
| 120 | +theta = np.linspace(0, 2 * np.pi, 200) |
| 121 | +circle_df = pd.DataFrame({"x": np.cos(theta), "y": np.sin(theta)}) |
| 122 | + |
| 123 | +# Tick marks every 10° (longer every 30°) |
| 124 | +tick_rows = [] |
| 125 | +for deg in range(0, 360, 10): |
| 126 | + rad = np.radians(deg) |
| 127 | + inner_r = 0.95 if deg % 30 != 0 else 0.92 |
| 128 | + tick_rows.append({"x": np.sin(rad) * inner_r, "y": np.cos(rad) * inner_r, "xend": np.sin(rad), "yend": np.cos(rad)}) |
| 129 | +tick_df = pd.DataFrame(tick_rows) |
| 130 | + |
| 131 | +# Reference cross lines (N-S, E-W) |
| 132 | +ref_df = pd.DataFrame( |
| 133 | + [{"x": 0, "y": -1, "xend": 0, "yend": 1, "group": 0}, {"x": -1, "y": 0, "xend": 1, "yend": 0, "group": 1}] |
| 134 | +) |
| 135 | + |
| 136 | +# Cardinal direction labels |
| 137 | +cardinal_positions = [(0, 1.12, "N"), (1.12, 0, "E"), (0, -1.12, "S"), (-1.12, 0, "W")] |
| 138 | +label_df = pd.DataFrame( |
| 139 | + { |
| 140 | + "x": [c[0] for c in cardinal_positions], |
| 141 | + "y": [c[1] for c in cardinal_positions], |
| 142 | + "label": [c[2] for c in cardinal_positions], |
| 143 | + } |
| 144 | +) |
| 145 | + |
| 146 | +# Mean pole annotations and confidence ellipses per feature type |
| 147 | +mean_annotations = [] |
| 148 | +ellipse_rows = [] |
| 149 | +for idx, ft in enumerate(["Bedding", "Joint", "Fault", "Foliation"]): |
| 150 | + mask = np.array(feature_types) == ft |
| 151 | + mx = pole_x[mask].mean() |
| 152 | + my = pole_y[mask].mean() |
| 153 | + # Circular mean for strike (handles wraparound at 0/360°) |
| 154 | + s_rad = np.radians(strikes[mask]) |
| 155 | + ms = np.degrees(np.arctan2(np.sin(s_rad).mean(), np.cos(s_rad).mean())) % 360 |
| 156 | + md = dips[mask].mean() |
| 157 | + # Smart label nudge: avoid overlap with cardinal labels (N/E/S/W) |
| 158 | + nudge_x, nudge_y = 0.0, -0.13 |
| 159 | + for cx, cy, _ in cardinal_positions: |
| 160 | + dist = np.sqrt((mx - cx) ** 2 + (my - cy) ** 2) |
| 161 | + if dist < 0.35: |
| 162 | + # Push label away from cardinal direction |
| 163 | + dx, dy = mx - cx, my - cy |
| 164 | + norm = max(np.sqrt(dx**2 + dy**2), 0.01) |
| 165 | + nudge_x = dx / norm * 0.15 |
| 166 | + nudge_y = dy / norm * 0.15 - 0.08 |
| 167 | + mean_annotations.append( |
| 168 | + {"x": mx, "y": my, "label": f"{ft}\n{ms:.0f}/{md:.0f}", "nudge_x": nudge_x, "nudge_y": nudge_y} |
| 169 | + ) |
| 170 | + # Confidence ellipse (1-sigma) around each cluster |
| 171 | + px_ft = pole_x[mask] |
| 172 | + py_ft = pole_y[mask] |
| 173 | + cov = np.cov(px_ft, py_ft) |
| 174 | + eigvals, eigvecs = np.linalg.eigh(cov) |
| 175 | + angle = np.arctan2(eigvecs[1, 1], eigvecs[0, 1]) |
| 176 | + t = np.linspace(0, 2 * np.pi, 40) |
| 177 | + ex = np.sqrt(eigvals[1]) * np.cos(t) |
| 178 | + ey = np.sqrt(eigvals[0]) * np.sin(t) |
| 179 | + rx = mx + ex * np.cos(angle) - ey * np.sin(angle) |
| 180 | + ry = my + ex * np.sin(angle) + ey * np.cos(angle) |
| 181 | + for j in range(len(t)): |
| 182 | + ellipse_rows.append({"x": rx[j], "y": ry[j], "group": idx, "feature_type": ft}) |
| 183 | + |
| 184 | +ellipse_df = pd.DataFrame(ellipse_rows) |
| 185 | + |
| 186 | +# Split mean annotations by nudge direction for separate geom_text layers |
| 187 | +mean_df_list = [] |
| 188 | +for ann in mean_annotations: |
| 189 | + mean_df_list.append(ann) |
| 190 | +mean_df = pd.DataFrame(mean_df_list) |
| 191 | + |
| 192 | +# Colorblind-safe palette (4 feature types) |
| 193 | +color_values = ["#306998", "#CC79A7", "#E69F00", "#009E73"] |
| 194 | + |
| 195 | +# Tooltips for poles showing strike/dip |
| 196 | +pole_tooltips = layer_tooltips().line("@feature_type").line("Strike: @strike").line("Dip: @dip") |
| 197 | + |
| 198 | +# Build individual mean label layers to handle per-label nudge offsets |
| 199 | +mean_label_layers = [] |
| 200 | +for _, row in mean_df.iterrows(): |
| 201 | + single_df = pd.DataFrame([{"x": row["x"], "y": row["y"], "label": row["label"]}]) |
| 202 | + mean_label_layers.append( |
| 203 | + geom_text( |
| 204 | + aes(x="x", y="y", label="label"), |
| 205 | + data=single_df, |
| 206 | + size=14, |
| 207 | + color="#222222", |
| 208 | + nudge_x=row["nudge_x"], |
| 209 | + nudge_y=row["nudge_y"], |
| 210 | + fontface="italic", |
| 211 | + show_legend=False, |
| 212 | + ) |
| 213 | + ) |
| 214 | + |
| 215 | +# Plot |
| 216 | +plot = ( |
| 217 | + ggplot() |
| 218 | + # Reference cross lines |
| 219 | + + geom_segment( |
| 220 | + aes(x="x", y="y", xend="xend", yend="yend"), data=ref_df, color="#E0E0E0", size=0.4, linetype="dashed" |
| 221 | + ) |
| 222 | + # Density contours using lets-plot built-in geom_density2d (Kamb-style) |
| 223 | + + geom_density2d( |
| 224 | + aes(x="x", y="y"), |
| 225 | + data=poles_df, |
| 226 | + color="#999999", |
| 227 | + alpha=0.5, |
| 228 | + size=0.5, |
| 229 | + bins=6, |
| 230 | + kernel="gaussian", |
| 231 | + adjust=0.8, |
| 232 | + show_legend=False, |
| 233 | + ) |
| 234 | + # Great circles |
| 235 | + + geom_path(aes(x="x", y="y", group="group", color="feature_type"), data=gc_df, size=0.35, alpha=0.25) |
| 236 | + # Confidence ellipses around each cluster |
| 237 | + + geom_polygon( |
| 238 | + aes(x="x", y="y", group="group", fill="feature_type"), data=ellipse_df, alpha=0.10, size=0, show_legend=False |
| 239 | + ) |
| 240 | + # Poles to planes with tooltips |
| 241 | + + geom_point(aes(x="x", y="y", color="feature_type"), data=poles_df, size=5, alpha=0.85, tooltips=pole_tooltips) |
| 242 | + # Mean pole markers (diamond shape, dark) |
| 243 | + + geom_point(aes(x="x", y="y"), data=mean_df, size=10, shape=18, color="#222222", alpha=0.95, show_legend=False) |
| 244 | +) |
| 245 | + |
| 246 | +# Add per-label mean annotation layers |
| 247 | +for layer in mean_label_layers: |
| 248 | + plot = plot + layer |
| 249 | + |
| 250 | +plot = ( |
| 251 | + plot |
| 252 | + # Primitive circle |
| 253 | + + geom_path(aes(x="x", y="y"), data=circle_df, color="#2A2A2A", size=1.3) |
| 254 | + # Tick marks |
| 255 | + + geom_segment(aes(x="x", y="y", xend="xend", yend="yend"), data=tick_df, color="#2A2A2A", size=0.8) |
| 256 | + # Cardinal labels |
| 257 | + + geom_text(aes(x="x", y="y", label="label"), data=label_df, size=20, color="#1A1A1A", fontface="bold") |
| 258 | + # Color scale |
| 259 | + + scale_color_manual(values=color_values, name="Feature Type") |
| 260 | + + scale_fill_manual(values=color_values) |
| 261 | + + coord_fixed() |
| 262 | + + scale_x_continuous(limits=[-1.3, 1.3]) |
| 263 | + + scale_y_continuous(limits=[-1.35, 1.3]) |
| 264 | + + labs(title="stereonet-equal-area · letsplot · pyplots.ai") |
| 265 | + + theme( |
| 266 | + plot_title=element_text(size=26, hjust=0.5, face="bold", margin=[0, 0, 16, 0]), |
| 267 | + legend_title=element_text(size=18, face="bold"), |
| 268 | + legend_text=element_text(size=16), |
| 269 | + legend_position=[0.85, 0.15], |
| 270 | + legend_justification=[0.5, 0.5], |
| 271 | + legend_background=element_rect(fill="white", color="#CCCCCC", size=0.5), |
| 272 | + axis_title=element_blank(), |
| 273 | + axis_text=element_blank(), |
| 274 | + axis_ticks=element_blank(), |
| 275 | + axis_line=element_blank(), |
| 276 | + panel_grid=element_blank(), |
| 277 | + plot_background=element_rect(fill="white"), |
| 278 | + panel_background=element_rect(fill="#FAFAFA"), |
| 279 | + ) |
| 280 | + + ggsize(1200, 1200) |
| 281 | +) |
| 282 | + |
| 283 | +ggsave(plot, "plot.png", path=".", scale=3) |
| 284 | +ggsave(plot, "plot.html", path=".") |
0 commit comments