Skip to content

Commit 6799fd9

Browse files
feat(plotnine): implement stereonet-equal-area
1 parent 4a54060 commit 6799fd9

1 file changed

Lines changed: 186 additions & 0 deletions

File tree

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""pyplots.ai
2+
stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection)
3+
Library: plotnine | Python 3.13
4+
Quality: pending | Created: 2026-03-15
5+
"""
6+
7+
import matplotlib
8+
import matplotlib.pyplot as plt
9+
import numpy as np
10+
import pandas as pd
11+
from plotnine import (
12+
aes,
13+
coord_fixed,
14+
element_blank,
15+
element_text,
16+
geom_path,
17+
geom_point,
18+
geom_segment,
19+
geom_text,
20+
ggplot,
21+
labs,
22+
scale_color_manual,
23+
scale_x_continuous,
24+
scale_y_continuous,
25+
theme,
26+
)
27+
from scipy.ndimage import gaussian_filter
28+
29+
30+
matplotlib.use("Agg")
31+
32+
# Data - Geological field measurements (strike/dip for bedding, faults, joints)
33+
np.random.seed(42)
34+
35+
bedding_strike = np.random.normal(45, 12, 40)
36+
bedding_dip = np.clip(np.random.normal(30, 8, 40), 5, 85)
37+
38+
fault_strike = np.random.normal(150, 15, 25)
39+
fault_dip = np.clip(np.random.normal(65, 10, 25), 10, 88)
40+
41+
joint_strike = np.random.normal(280, 20, 35)
42+
joint_dip = np.clip(np.random.normal(75, 8, 35), 15, 89)
43+
44+
strikes = np.concatenate([bedding_strike, fault_strike, joint_strike]) % 360
45+
dips = np.concatenate([bedding_dip, fault_dip, joint_dip])
46+
feature_types = ["Bedding"] * 40 + ["Fault"] * 25 + ["Joint"] * 35
47+
48+
# Primitive circle radius for equal-area projection
49+
r_prim = np.sqrt(2)
50+
51+
# Compute poles (equal-area Schmidt projection)
52+
pole_trend = np.radians((strikes + 90) % 360)
53+
pole_plunge = np.radians(90 - dips)
54+
pole_r = np.sqrt(2) * np.sin((np.pi / 2 - pole_plunge) / 2)
55+
pole_x = pole_r * np.sin(pole_trend)
56+
pole_y = pole_r * np.cos(pole_trend)
57+
poles_df = pd.DataFrame({"x": pole_x, "y": pole_y, "feature_type": feature_types})
58+
59+
# Compute great circles (sample representative planes per type)
60+
gc_rows = []
61+
gc_indices = {"Bedding": [0, 10, 20, 30], "Fault": [40, 48, 56], "Joint": [65, 75, 85]}
62+
gc_id = 0
63+
for ftype, indices in gc_indices.items():
64+
for idx in indices:
65+
if idx >= len(strikes):
66+
continue
67+
strike_rad = np.radians(strikes[idx])
68+
dip_rad = np.radians(dips[idx])
69+
strike_vec = np.array([np.sin(strike_rad), np.cos(strike_rad), 0.0])
70+
dip_dir_rad = strike_rad + np.pi / 2
71+
dip_vec = np.array(
72+
[np.sin(dip_dir_rad) * np.cos(dip_rad), np.cos(dip_dir_rad) * np.cos(dip_rad), -np.sin(dip_rad)]
73+
)
74+
angles = np.linspace(-np.pi / 2, np.pi / 2, 181)
75+
for a in angles:
76+
pt = np.cos(a) * dip_vec + np.sin(a) * strike_vec
77+
if pt[2] > 0:
78+
pt = -pt
79+
horiz = np.sqrt(pt[0] ** 2 + pt[1] ** 2)
80+
plunge = np.arctan2(-pt[2], horiz)
81+
trend = np.arctan2(pt[0], pt[1])
82+
r = np.sqrt(2) * np.sin((np.pi / 2 - plunge) / 2)
83+
x = r * np.sin(trend)
84+
y = r * np.cos(trend)
85+
if x**2 + y**2 <= r_prim**2 * 1.01:
86+
gc_rows.append({"x": x, "y": y, "feature_type": ftype, "gc_id": f"{ftype}_{gc_id}"})
87+
gc_id += 1
88+
89+
gc_df = pd.DataFrame(gc_rows)
90+
91+
# Stereonet grid - primitive circle
92+
circle_angles = np.linspace(0, 2 * np.pi, 361)
93+
prim_df = pd.DataFrame({"x": r_prim * np.cos(circle_angles), "y": r_prim * np.sin(circle_angles), "group": "primitive"})
94+
95+
# Degree tick marks every 10 degrees
96+
tick_rows = []
97+
for deg in range(0, 360, 10):
98+
rad = np.radians(deg)
99+
inner = r_prim * 0.97
100+
outer = r_prim * 1.0
101+
tick_rows.append(
102+
{"x1": inner * np.sin(rad), "y1": inner * np.cos(rad), "x2": outer * np.sin(rad), "y2": outer * np.cos(rad)}
103+
)
104+
tick_df = pd.DataFrame(tick_rows)
105+
106+
# Cardinal direction labels
107+
dir_labels = []
108+
for deg, label in [(0, "N"), (90, "E"), (180, "S"), (270, "W")]:
109+
rad = np.radians(deg)
110+
offset = r_prim * 1.08
111+
dir_labels.append({"x": offset * np.sin(rad), "y": offset * np.cos(rad), "label": label})
112+
dir_df = pd.DataFrame(dir_labels)
113+
114+
# Density contours (Kamb-style kernel density on projected pole coordinates)
115+
grid_res = 200
116+
gx_lin = np.linspace(-r_prim, r_prim, grid_res)
117+
gy_lin = np.linspace(-r_prim, r_prim, grid_res)
118+
gx_grid, gy_grid = np.meshgrid(gx_lin, gy_lin)
119+
density = np.zeros_like(gx_grid)
120+
121+
sigma = 0.15
122+
for i in range(len(pole_x)):
123+
dist_sq = (gx_grid - pole_x[i]) ** 2 + (gy_grid - pole_y[i]) ** 2
124+
density += np.exp(-dist_sq / (2 * sigma**2))
125+
126+
density = gaussian_filter(density, sigma=3)
127+
circle_mask = gx_grid**2 + gy_grid**2 > r_prim**2
128+
density[circle_mask] = np.nan
129+
130+
# Extract contour coordinates (using matplotlib for numerical contour extraction only)
131+
fig_tmp, ax_tmp = plt.subplots()
132+
valid_density = density[~np.isnan(density)]
133+
levels = np.linspace(valid_density.max() * 0.2, valid_density.max() * 0.8, 5)
134+
cs = ax_tmp.contour(gx_lin, gy_lin, density, levels=levels)
135+
plt.close(fig_tmp)
136+
137+
contour_rows = []
138+
contour_id = 0
139+
for level_segs in cs.allsegs:
140+
for seg in level_segs:
141+
if len(seg) > 0:
142+
for xi, yi in seg:
143+
if xi**2 + yi**2 <= r_prim**2 * 1.01:
144+
contour_rows.append({"x": xi, "y": yi, "contour_id": contour_id})
145+
contour_id += 1
146+
147+
contour_df = pd.DataFrame(contour_rows) if contour_rows else pd.DataFrame({"x": [], "y": [], "contour_id": []})
148+
149+
# Colors
150+
colors = {"Bedding": "#306998", "Fault": "#C44E52", "Joint": "#55A868"}
151+
152+
# Plot
153+
plot = (
154+
ggplot()
155+
+ geom_path(aes(x="x", y="y"), data=prim_df, color="#333333", size=1.2)
156+
+ geom_segment(aes(x="x1", y="y1", xend="x2", yend="y2"), data=tick_df, color="#333333", size=0.5)
157+
+ geom_path(
158+
aes(x="x", y="y", group="contour_id"), data=contour_df, color="#999999", size=0.5, alpha=0.5, linetype="dashed"
159+
)
160+
+ geom_path(aes(x="x", y="y", color="feature_type", group="gc_id"), data=gc_df, size=0.8, alpha=0.7)
161+
+ geom_point(aes(x="x", y="y", color="feature_type"), data=poles_df, size=3, alpha=0.8, stroke=0.5)
162+
+ geom_text(aes(x="x", y="y", label="label"), data=dir_df, size=16, fontweight="bold", color="#333333")
163+
+ scale_color_manual(name="Feature Type", values=colors)
164+
+ coord_fixed(ratio=1)
165+
+ scale_x_continuous(limits=(-1.7, 1.7))
166+
+ scale_y_continuous(limits=(-1.7, 1.7))
167+
+ labs(title="stereonet-equal-area · plotnine · pyplots.ai")
168+
+ theme(
169+
figure_size=(12, 12),
170+
plot_title=element_text(size=24, ha="center"),
171+
axis_title=element_blank(),
172+
axis_text=element_blank(),
173+
axis_ticks=element_blank(),
174+
axis_line=element_blank(),
175+
panel_grid_major=element_blank(),
176+
panel_grid_minor=element_blank(),
177+
panel_background=element_blank(),
178+
plot_background=element_blank(),
179+
legend_title=element_text(size=16),
180+
legend_text=element_text(size=14),
181+
legend_position="right",
182+
)
183+
)
184+
185+
# Save
186+
plot.save("plot.png", dpi=300)

0 commit comments

Comments
 (0)