|
| 1 | +"""pyplots.ai |
| 2 | +stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection) |
| 3 | +Library: bokeh | Python 3.13 |
| 4 | +Quality: pending | Created: 2026-03-15 |
| 5 | +""" |
| 6 | + |
| 7 | +import matplotlib |
| 8 | + |
| 9 | + |
| 10 | +matplotlib.use("Agg") |
| 11 | +import matplotlib.pyplot as plt |
| 12 | +import numpy as np |
| 13 | +from bokeh.io import export_png, output_file, save |
| 14 | +from bokeh.models import ColumnDataSource, Label, Legend, LegendItem |
| 15 | +from bokeh.plotting import figure |
| 16 | +from scipy.stats import gaussian_kde |
| 17 | + |
| 18 | + |
| 19 | +# Data - Synthetic structural geology measurements (strike, dip, feature_type) |
| 20 | +np.random.seed(42) |
| 21 | + |
| 22 | +# Bedding planes: consistent NE strike with moderate dip |
| 23 | +bedding_strike = np.random.normal(45, 12, 40) % 360 |
| 24 | +bedding_dip = np.random.normal(35, 8, 40).clip(5, 85) |
| 25 | + |
| 26 | +# Joint set 1: roughly E-W strike, steep dip |
| 27 | +joints_strike = np.random.normal(270, 15, 35) % 360 |
| 28 | +joints_dip = np.random.normal(75, 10, 35).clip(5, 89) |
| 29 | + |
| 30 | +# Fault set: NW strike, moderate-steep dip |
| 31 | +faults_strike = np.random.normal(315, 10, 25) % 360 |
| 32 | +faults_dip = np.random.normal(60, 12, 25).clip(5, 89) |
| 33 | + |
| 34 | +all_strikes = np.concatenate([bedding_strike, joints_strike, faults_strike]) |
| 35 | +all_dips = np.concatenate([bedding_dip, joints_dip, faults_dip]) |
| 36 | +all_types = ["Bedding"] * 40 + ["Joints"] * 35 + ["Faults"] * 25 |
| 37 | +colors_map = {"Bedding": "#306998", "Joints": "#E8833A", "Faults": "#8B5CF6"} |
| 38 | + |
| 39 | +# Equal-area projection: convert pole (plunge, trend) to x, y |
| 40 | +# Pole to a plane: trend = dip_direction + 180, plunge = 90 - dip |
| 41 | +# Dip direction = strike + 90 (right-hand rule) |
| 42 | +R_net = 1.0 |
| 43 | + |
| 44 | +pole_trends = (all_strikes + 90 + 180) % 360 |
| 45 | +pole_plunges = 90.0 - all_dips |
| 46 | +pole_trends_rad = np.radians(pole_trends) |
| 47 | +pole_plunges_rad = np.radians(pole_plunges) |
| 48 | + |
| 49 | +pole_r = R_net * np.sqrt(2) * np.sin((np.pi / 2 - pole_plunges_rad) / 2) |
| 50 | +pole_x = pole_r * np.sin(pole_trends_rad) |
| 51 | +pole_y = pole_r * np.cos(pole_trends_rad) |
| 52 | + |
| 53 | +# Great circles for each plane |
| 54 | +# Parameterize: for rake angle alpha from 0 to pi, compute line in the plane |
| 55 | +# then project to equal-area |
| 56 | +gc_xs = [] |
| 57 | +gc_ys = [] |
| 58 | +gc_types = [] |
| 59 | + |
| 60 | +for i in range(len(all_strikes)): |
| 61 | + strike_rad = np.radians(all_strikes[i]) |
| 62 | + dip_rad = np.radians(all_dips[i]) |
| 63 | + dd_rad = strike_rad + np.pi / 2 |
| 64 | + |
| 65 | + # Strike vector (horizontal, in plane) |
| 66 | + sx = np.sin(strike_rad) |
| 67 | + sy = np.cos(strike_rad) |
| 68 | + |
| 69 | + # Down-dip vector (in the plane, dipping) |
| 70 | + dx = np.sin(dd_rad) * np.cos(dip_rad) |
| 71 | + dy = np.cos(dd_rad) * np.cos(dip_rad) |
| 72 | + dz = -np.sin(dip_rad) |
| 73 | + |
| 74 | + # Parameterize great circle: v = cos(alpha)*s_vec + sin(alpha)*d_vec |
| 75 | + alpha = np.linspace(0, np.pi, 90) |
| 76 | + vx = np.cos(alpha) * sx + np.sin(alpha) * dx |
| 77 | + vy = np.cos(alpha) * sy + np.sin(alpha) * dy |
| 78 | + vz = np.sin(alpha) * dz |
| 79 | + |
| 80 | + # Convert to plunge and trend |
| 81 | + horiz = np.sqrt(vx**2 + vy**2) |
| 82 | + plunge = np.arctan2(-vz, horiz) |
| 83 | + trend = np.arctan2(vx, vy) |
| 84 | + |
| 85 | + # Equal-area projection |
| 86 | + r = R_net * np.sqrt(2) * np.sin((np.pi / 2 - plunge) / 2) |
| 87 | + gx = r * np.sin(trend) |
| 88 | + gy = r * np.cos(trend) |
| 89 | + |
| 90 | + gc_xs.append(gx.tolist()) |
| 91 | + gc_ys.append(gy.tolist()) |
| 92 | + gc_types.append(all_types[i]) |
| 93 | + |
| 94 | +# Kamb density contours on pole data |
| 95 | +grid_n = 200 |
| 96 | +gx_lin = np.linspace(-1.05, 1.05, grid_n) |
| 97 | +gy_lin = np.linspace(-1.05, 1.05, grid_n) |
| 98 | +gx_grid, gy_grid = np.meshgrid(gx_lin, gy_lin) |
| 99 | + |
| 100 | +# Mask to circular region |
| 101 | +dist_grid = np.sqrt(gx_grid**2 + gy_grid**2) |
| 102 | +mask = dist_grid <= R_net |
| 103 | + |
| 104 | +# KDE on pole positions |
| 105 | +pole_xy = np.vstack([pole_x, pole_y]) |
| 106 | +kde = gaussian_kde(pole_xy, bw_method=0.2) |
| 107 | +density = kde(np.vstack([gx_grid.ravel(), gy_grid.ravel()])).reshape(grid_n, grid_n) |
| 108 | +density[~mask] = 0 |
| 109 | + |
| 110 | +# Extract contour lines using matplotlib (computation only, not for display) |
| 111 | +fig_temp, ax_temp = plt.subplots() |
| 112 | +levels = np.linspace(density[mask].max() * 0.2, density[mask].max() * 0.9, 5) |
| 113 | +cs = ax_temp.contour(gx_lin, gy_lin, density, levels=levels) |
| 114 | +contour_xs = [] |
| 115 | +contour_ys = [] |
| 116 | +for level_segs in cs.allsegs: |
| 117 | + for seg in level_segs: |
| 118 | + # Clip to circle |
| 119 | + d = np.sqrt(seg[:, 0] ** 2 + seg[:, 1] ** 2) |
| 120 | + inside = d <= R_net * 1.01 |
| 121 | + if np.sum(inside) > 2: |
| 122 | + contour_xs.append(seg[inside, 0].tolist()) |
| 123 | + contour_ys.append(seg[inside, 1].tolist()) |
| 124 | +plt.close(fig_temp) |
| 125 | + |
| 126 | +# Plot - Square format for circular stereonet |
| 127 | +p = figure( |
| 128 | + width=3600, |
| 129 | + height=3600, |
| 130 | + title="stereonet-equal-area · bokeh · pyplots.ai", |
| 131 | + x_range=(-1.45, 1.45), |
| 132 | + y_range=(-1.40, 1.45), |
| 133 | + tools="pan,wheel_zoom,reset,save", |
| 134 | + toolbar_location=None, |
| 135 | + match_aspect=True, |
| 136 | +) |
| 137 | + |
| 138 | +# Primitive circle (outer boundary) |
| 139 | +theta_circle = np.linspace(0, 2 * np.pi, 360) |
| 140 | +circle_x = R_net * np.cos(theta_circle) |
| 141 | +circle_y = R_net * np.sin(theta_circle) |
| 142 | +p.line(circle_x, circle_y, line_color="#333333", line_width=3) |
| 143 | + |
| 144 | +# Tick marks every 10 degrees around perimeter |
| 145 | +for deg in range(0, 360, 10): |
| 146 | + rad = np.radians(deg) |
| 147 | + x_inner = 0.96 * R_net * np.sin(rad) |
| 148 | + y_inner = 0.96 * R_net * np.cos(rad) |
| 149 | + x_outer = 1.03 * R_net * np.sin(rad) |
| 150 | + y_outer = 1.03 * R_net * np.cos(rad) |
| 151 | + lw = 3 if deg % 90 == 0 else 2 |
| 152 | + p.line([x_inner, x_outer], [y_inner, y_outer], line_color="#333333", line_width=lw) |
| 153 | + |
| 154 | +# Cardinal direction labels |
| 155 | +for deg, label in [(0, "N"), (90, "E"), (180, "S"), (270, "W")]: |
| 156 | + rad = np.radians(deg) |
| 157 | + lx = 1.12 * R_net * np.sin(rad) |
| 158 | + ly = 1.12 * R_net * np.cos(rad) |
| 159 | + fs = "26pt" if label == "N" else "20pt" |
| 160 | + fw = "bold" if label == "N" else "normal" |
| 161 | + p.add_layout( |
| 162 | + Label( |
| 163 | + x=lx, |
| 164 | + y=ly, |
| 165 | + text=label, |
| 166 | + text_font_size=fs, |
| 167 | + text_font_style=fw, |
| 168 | + text_align="center", |
| 169 | + text_baseline="middle", |
| 170 | + text_color="#333333", |
| 171 | + ) |
| 172 | + ) |
| 173 | + |
| 174 | +# Density contour lines |
| 175 | +for cx, cy in zip(contour_xs, contour_ys, strict=True): |
| 176 | + p.line(cx, cy, line_color="#888888", line_width=2, line_alpha=0.5, line_dash="dotted") |
| 177 | + |
| 178 | +# Great circles by feature type (draw with low alpha for clarity) |
| 179 | +renderers_gc = {} |
| 180 | +for ftype in ["Bedding", "Joints", "Faults"]: |
| 181 | + idxs = [j for j, t in enumerate(gc_types) if t == ftype] |
| 182 | + fxs = [gc_xs[j] for j in idxs] |
| 183 | + fys = [gc_ys[j] for j in idxs] |
| 184 | + r = p.multi_line(fxs, fys, line_color=colors_map[ftype], line_width=2, line_alpha=0.35) |
| 185 | + renderers_gc[ftype] = r |
| 186 | + |
| 187 | +# Poles by feature type |
| 188 | +renderers_pole = {} |
| 189 | +for ftype in ["Bedding", "Joints", "Faults"]: |
| 190 | + idxs = [j for j, t in enumerate(all_types) if t == ftype] |
| 191 | + px = pole_x[idxs] |
| 192 | + py = pole_y[idxs] |
| 193 | + source = ColumnDataSource(data={"x": px, "y": py}) |
| 194 | + r = p.scatter( |
| 195 | + "x", "y", source=source, size=18, color=colors_map[ftype], line_color="white", line_width=1.5, alpha=0.85 |
| 196 | + ) |
| 197 | + renderers_pole[ftype] = r |
| 198 | + |
| 199 | +# Legend |
| 200 | +legend_items = [] |
| 201 | +for ftype in ["Bedding", "Joints", "Faults"]: |
| 202 | + legend_items.append(LegendItem(label=ftype, renderers=[renderers_gc[ftype], renderers_pole[ftype]])) |
| 203 | + |
| 204 | +legend = Legend(items=legend_items, location="top_right") |
| 205 | +legend.label_text_font_size = "20pt" |
| 206 | +legend.glyph_height = 30 |
| 207 | +legend.glyph_width = 30 |
| 208 | +legend.spacing = 10 |
| 209 | +legend.background_fill_alpha = 0.85 |
| 210 | +legend.border_line_color = "#cccccc" |
| 211 | +legend.border_line_width = 2 |
| 212 | +legend.padding = 15 |
| 213 | +p.add_layout(legend) |
| 214 | + |
| 215 | +# Style |
| 216 | +p.title.text_font_size = "30pt" |
| 217 | +p.title.align = "center" |
| 218 | +p.xaxis.visible = False |
| 219 | +p.yaxis.visible = False |
| 220 | +p.xgrid.visible = False |
| 221 | +p.ygrid.visible = False |
| 222 | +p.outline_line_color = None |
| 223 | +p.background_fill_color = "white" |
| 224 | + |
| 225 | +# Save |
| 226 | +export_png(p, filename="plot.png") |
| 227 | +output_file("plot.html") |
| 228 | +save(p) |
0 commit comments