Skip to content

Commit 53a4043

Browse files
feat(letsplot): implement stereonet-equal-area
1 parent 4a54060 commit 53a4043

1 file changed

Lines changed: 224 additions & 0 deletions

File tree

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
"""pyplots.ai
2+
stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection)
3+
Library: letsplot | Python 3.13
4+
Quality: pending | Created: 2026-03-15
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from lets_plot import * # noqa: F403
10+
from lets_plot.export import ggsave
11+
12+
13+
LetsPlot.setup_html()
14+
15+
# Data - Field measurements from a structural geology mapping campaign
16+
np.random.seed(42)
17+
18+
# Bedding planes (NE strike ~045, moderate dip ~35° SE)
19+
n_bedding = 25
20+
bedding_strike = np.random.normal(45, 12, n_bedding) % 360
21+
bedding_dip = np.clip(np.random.normal(35, 8, n_bedding), 5, 85)
22+
23+
# Joint set 1 (N-S striking ~000, steep dip ~78° W)
24+
n_j1 = 12
25+
j1_strike = np.random.normal(355, 10, n_j1) % 360
26+
j1_dip = np.clip(np.random.normal(78, 7, n_j1), 10, 89)
27+
28+
# Joint set 2 (E-W striking ~090, steep dip ~80° N)
29+
n_j2 = 10
30+
j2_strike = np.random.normal(90, 10, n_j2) % 360
31+
j2_dip = np.clip(np.random.normal(80, 6, n_j2), 10, 89)
32+
33+
# Faults (NW-SE striking ~150, moderate-steep dip ~60°)
34+
n_faults = 13
35+
faults_strike = np.random.uniform(130, 170, n_faults)
36+
faults_dip = np.clip(np.random.normal(60, 12, n_faults), 10, 89)
37+
38+
strikes = np.concatenate([bedding_strike, j1_strike, j2_strike, faults_strike])
39+
dips = np.concatenate([bedding_dip, j1_dip, j2_dip, faults_dip])
40+
feature_types = ["Bedding"] * n_bedding + ["Joint"] * (n_j1 + n_j2) + ["Fault"] * n_faults
41+
42+
# Equal-area (Schmidt net) lower-hemisphere projection
43+
strike_rad = np.radians(strikes)
44+
dip_rad = np.radians(dips)
45+
dip_dir_rad = strike_rad + np.pi / 2
46+
47+
# Pole 3D coordinates (lower hemisphere normal to each plane)
48+
pole_x_3d = np.sin(dip_rad) * np.sin(dip_dir_rad)
49+
pole_y_3d = np.sin(dip_rad) * np.cos(dip_dir_rad)
50+
pole_z_3d = -np.cos(dip_rad)
51+
52+
# Lambert azimuthal equal-area projection
53+
pole_scale = 1.0 / np.sqrt(1.0 - pole_z_3d)
54+
pole_x = pole_x_3d * pole_scale
55+
pole_y = pole_y_3d * pole_scale
56+
57+
poles_df = pd.DataFrame({"x": pole_x, "y": pole_y, "feature_type": feature_types})
58+
59+
# Great circle paths for each plane
60+
gc_rows = []
61+
alphas = np.linspace(0, np.pi, 60)
62+
cos_a = np.cos(alphas)
63+
sin_a = np.sin(alphas)
64+
65+
for i in range(len(strikes)):
66+
s = strike_rad[i]
67+
d = dip_rad[i]
68+
dd = dip_dir_rad[i]
69+
70+
# Strike vector (horizontal) and dip vector (in the plane, pointing downdip)
71+
sv_x, sv_y = np.sin(s), np.cos(s)
72+
dv_x = np.cos(d) * np.sin(dd)
73+
dv_y = np.cos(d) * np.cos(dd)
74+
dv_z = -np.sin(d)
75+
76+
# Parametric great circle: P(a) = cos(a)*strike_vec + sin(a)*dip_vec
77+
px = cos_a * sv_x + sin_a * dv_x
78+
py = cos_a * sv_y + sin_a * dv_y
79+
pz = sin_a * dv_z
80+
81+
# Equal-area projection
82+
sc = 1.0 / np.sqrt(1.0 - pz)
83+
gx = px * sc
84+
gy = py * sc
85+
86+
for j in range(len(alphas)):
87+
gc_rows.append({"x": gx[j], "y": gy[j], "group": i, "feature_type": feature_types[i]})
88+
89+
gc_df = pd.DataFrame(gc_rows)
90+
91+
# Kamb-style density contours using Gaussian KDE on projected pole coordinates
92+
grid_n = 100
93+
gx_arr = np.linspace(-1.0, 1.0, grid_n)
94+
gy_arr = np.linspace(-1.0, 1.0, grid_n)
95+
gxx, gyy = np.meshgrid(gx_arr, gy_arr)
96+
inside_circle = (gxx**2 + gyy**2) <= 1.0
97+
98+
# Manual Gaussian KDE
99+
n_pts = len(pole_x)
100+
bw = 0.15
101+
density = np.zeros((grid_n, grid_n))
102+
for k in range(n_pts):
103+
dx = gxx - pole_x[k]
104+
dy = gyy - pole_y[k]
105+
density += np.exp(-0.5 * (dx**2 + dy**2) / bw**2)
106+
density /= n_pts * 2 * np.pi * bw**2
107+
density[~inside_circle] = np.nan
108+
109+
# Extract contour paths manually using marching squares
110+
# Compute contour levels
111+
d_valid = density[inside_circle]
112+
d_max = np.nanmax(d_valid)
113+
d_min = np.nanmin(d_valid[d_valid > 0])
114+
levels = np.linspace(d_min + (d_max - d_min) * 0.2, d_max * 0.85, 5)
115+
116+
contour_rows = []
117+
contour_id = 0
118+
dx_step = gx_arr[1] - gx_arr[0]
119+
dy_step = gy_arr[1] - gy_arr[0]
120+
121+
for level in levels:
122+
# Find cells where contour crosses (simple marching squares)
123+
for row in range(grid_n - 1):
124+
for col in range(grid_n - 1):
125+
corners = [density[row, col], density[row, col + 1], density[row + 1, col + 1], density[row + 1, col]]
126+
if any(np.isnan(c) for c in corners):
127+
continue
128+
above = [c >= level for c in corners]
129+
if all(above) or not any(above):
130+
continue
131+
132+
# Find intersection points on edges
133+
edges = []
134+
edge_pairs = [(0, 1), (1, 2), (2, 3), (3, 0)]
135+
corner_coords = [
136+
(gx_arr[col], gy_arr[row]),
137+
(gx_arr[col + 1], gy_arr[row]),
138+
(gx_arr[col + 1], gy_arr[row + 1]),
139+
(gx_arr[col], gy_arr[row + 1]),
140+
]
141+
for ci, cj in edge_pairs:
142+
if above[ci] != above[cj]:
143+
t = (level - corners[ci]) / (corners[cj] - corners[ci])
144+
ex = corner_coords[ci][0] + t * (corner_coords[cj][0] - corner_coords[ci][0])
145+
ey = corner_coords[ci][1] + t * (corner_coords[cj][1] - corner_coords[ci][1])
146+
if ex**2 + ey**2 <= 1.02:
147+
edges.append((ex, ey))
148+
149+
if len(edges) >= 2:
150+
contour_rows.append({"x": edges[0][0], "y": edges[0][1], "group": contour_id})
151+
contour_rows.append({"x": edges[1][0], "y": edges[1][1], "group": contour_id})
152+
contour_id += 1
153+
154+
contour_df = pd.DataFrame(contour_rows)
155+
156+
# Primitive circle (outer boundary)
157+
theta = np.linspace(0, 2 * np.pi, 200)
158+
circle_df = pd.DataFrame({"x": np.cos(theta), "y": np.sin(theta)})
159+
160+
# Tick marks every 10° (longer every 30°)
161+
tick_rows = []
162+
for deg in range(0, 360, 10):
163+
rad = np.radians(deg)
164+
inner_r = 0.95 if deg % 30 != 0 else 0.92
165+
tick_rows.append({"x": np.sin(rad) * inner_r, "y": np.cos(rad) * inner_r, "xend": np.sin(rad), "yend": np.cos(rad)})
166+
167+
tick_df = pd.DataFrame(tick_rows)
168+
169+
# Reference cross lines (N-S, E-W)
170+
ref_df = pd.DataFrame(
171+
[{"x": 0, "y": -1, "xend": 0, "yend": 1, "group": 0}, {"x": -1, "y": 0, "xend": 1, "yend": 0, "group": 1}]
172+
)
173+
174+
# Cardinal direction labels
175+
label_df = pd.DataFrame({"x": [0, 1.09, 0, -1.09], "y": [1.09, 0, -1.09, 0], "label": ["N", "E", "S", "W"]})
176+
177+
# Colorblind-safe palette
178+
color_values = ["#306998", "#CC79A7", "#E69F00"]
179+
180+
# Plot
181+
plot = (
182+
ggplot()
183+
# Reference cross lines
184+
+ geom_segment(
185+
aes(x="x", y="y", xend="xend", yend="yend"), data=ref_df, color="#DDDDDD", size=0.5, linetype="dashed"
186+
)
187+
# Density contour segments (Kamb-style)
188+
+ geom_line(aes(x="x", y="y", group="group"), data=contour_df, color="#999999", size=0.5, alpha=0.5)
189+
# Great circles
190+
+ geom_path(aes(x="x", y="y", group="group", color="feature_type"), data=gc_df, size=0.5, alpha=0.35)
191+
# Poles to planes
192+
+ geom_point(aes(x="x", y="y", color="feature_type"), data=poles_df, size=5, alpha=0.85)
193+
# Primitive circle
194+
+ geom_path(aes(x="x", y="y"), data=circle_df, color="#333333", size=1.2)
195+
# Tick marks
196+
+ geom_segment(aes(x="x", y="y", xend="xend", yend="yend"), data=tick_df, color="#333333", size=0.7)
197+
# Cardinal labels
198+
+ geom_text(aes(x="x", y="y", label="label"), data=label_df, size=18, color="#333333", fontface="bold")
199+
# Color scale
200+
+ scale_color_manual(values=color_values, name="Feature Type")
201+
# Fixed aspect ratio
202+
+ coord_fixed()
203+
+ scale_x_continuous(limits=[-1.25, 1.25])
204+
+ scale_y_continuous(limits=[-1.25, 1.25])
205+
# Title
206+
+ labs(title="stereonet-equal-area · letsplot · pyplots.ai")
207+
# Theme
208+
+ theme(
209+
plot_title=element_text(size=24, hjust=0.5),
210+
legend_title=element_text(size=18),
211+
legend_text=element_text(size=16),
212+
legend_position="right",
213+
axis_title=element_blank(),
214+
axis_text=element_blank(),
215+
axis_ticks=element_blank(),
216+
axis_line=element_blank(),
217+
panel_grid=element_blank(),
218+
)
219+
+ ggsize(1200, 1200)
220+
)
221+
222+
# Save
223+
ggsave(plot, "plot.png", path=".", scale=3)
224+
ggsave(plot, "plot.html", path=".")

0 commit comments

Comments
 (0)