Skip to content

Commit 437ffdc

Browse files
feat(bokeh): implement stereonet-equal-area
1 parent 4a54060 commit 437ffdc

1 file changed

Lines changed: 228 additions & 0 deletions

File tree

  • plots/stereonet-equal-area/implementations
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
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

Comments
 (0)