Skip to content

Commit 94e258a

Browse files
feat(matplotlib): implement stereonet-equal-area (#4916)
## Implementation: `stereonet-equal-area` - matplotlib Implements the **matplotlib** version of `stereonet-equal-area`. **File:** `plots/stereonet-equal-area/implementations/matplotlib.py` **Parent Issue:** #4576 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23121188769)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 6004102 commit 94e258a

2 files changed

Lines changed: 410 additions & 0 deletions

File tree

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
""" pyplots.ai
2+
stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection)
3+
Library: matplotlib 3.10.8 | Python 3.14.3
4+
Quality: 90/100 | Created: 2026-03-15
5+
"""
6+
7+
import matplotlib.patheffects as pe
8+
import matplotlib.pyplot as plt
9+
import numpy as np
10+
from matplotlib.collections import LineCollection
11+
12+
13+
# Data - structural geology field measurements (strike/dip format)
14+
np.random.seed(42)
15+
16+
bedding_strike = np.random.normal(45, 12, 30) % 360
17+
bedding_dip = np.clip(np.random.normal(30, 8, 30), 2, 88)
18+
19+
joint_strike = np.random.normal(300, 10, 25) % 360
20+
joint_dip = np.clip(np.random.normal(72, 7, 25), 2, 88)
21+
22+
fault_strike = np.random.normal(185, 15, 20) % 360
23+
fault_dip = np.clip(np.random.normal(58, 10, 20), 2, 88)
24+
25+
datasets = {
26+
"Bedding": (bedding_strike, bedding_dip),
27+
"Joint": (joint_strike, joint_dip),
28+
"Fault": (fault_strike, fault_dip),
29+
}
30+
all_strikes = np.concatenate([bedding_strike, joint_strike, fault_strike])
31+
all_dips = np.concatenate([bedding_dip, joint_dip, fault_dip])
32+
feature_types = ["Bedding"] * 30 + ["Joint"] * 25 + ["Fault"] * 20
33+
34+
# Visual hierarchy: Bedding is primary focus (thicker, more opaque)
35+
colors = {"Bedding": "#306998", "Joint": "#E8833A", "Fault": "#8B2252"}
36+
markers = {"Bedding": "o", "Joint": "s", "Fault": "^"}
37+
pole_sizes = {"Bedding": 200, "Joint": 140, "Fault": 140}
38+
gc_alpha = {"Bedding": 0.55, "Joint": 0.25, "Fault": 0.25}
39+
gc_lw = {"Bedding": 1.8, "Joint": 0.9, "Fault": 0.9}
40+
41+
# Pole orientations in equal-area (Schmidt) projection
42+
pole_trend_rad = np.deg2rad((all_strikes + 90) % 360)
43+
pole_r = np.sqrt(2) * np.sin(np.deg2rad(all_dips) / 2)
44+
45+
# Pole unit vectors on sphere (East, North, Down coordinates)
46+
pole_colat = np.deg2rad(all_dips)
47+
pole_vx = np.sin(pole_colat) * np.sin(pole_trend_rad)
48+
pole_vy = np.sin(pole_colat) * np.cos(pole_trend_rad)
49+
pole_vz = np.cos(pole_colat)
50+
51+
# Plot
52+
fig = plt.figure(figsize=(12, 12), facecolor="white")
53+
ax = fig.add_subplot(111, projection="polar")
54+
ax.set_theta_zero_location("N")
55+
ax.set_theta_direction(-1)
56+
ax.set_facecolor("#FAFAF8")
57+
58+
# Density contours using spherical Gaussian kernel on pole data
59+
theta_grid = np.linspace(0, 2 * np.pi, 150)
60+
r_grid = np.linspace(0.02, 0.98, 75)
61+
THETA, R = np.meshgrid(theta_grid, r_grid)
62+
colat_grid = 2 * np.arcsin(np.clip(R / np.sqrt(2), 0, 1))
63+
gx = np.sin(colat_grid) * np.sin(THETA)
64+
gy = np.sin(colat_grid) * np.cos(THETA)
65+
gz = np.cos(colat_grid)
66+
67+
sigma = 0.20
68+
Z = np.zeros(THETA.shape)
69+
for j in range(len(all_dips)):
70+
cos_dist = gx * pole_vx[j] + gy * pole_vy[j] + gz * pole_vz[j]
71+
Z += np.exp(-(np.arccos(np.clip(cos_dist, -1, 1)) ** 2) / (2 * sigma**2))
72+
73+
contour_fill = ax.contourf(THETA, R, Z, levels=10, cmap="YlOrBr", alpha=0.35, zorder=1)
74+
ax.contour(THETA, R, Z, levels=10, colors="#B87333", alpha=0.35, linewidths=0.5, zorder=1)
75+
76+
# Great circles - representative subset only (5 per group to reduce clutter)
77+
t_param = np.linspace(0, np.pi, 180)
78+
79+
80+
def draw_great_circle(strike_deg, dip_deg):
81+
alpha = np.deg2rad(strike_deg)
82+
delta = np.deg2rad(dip_deg)
83+
px = np.cos(t_param) * np.sin(alpha) + np.sin(t_param) * np.cos(alpha) * np.cos(delta)
84+
py = np.cos(t_param) * np.cos(alpha) - np.sin(t_param) * np.sin(alpha) * np.cos(delta)
85+
pz = np.sin(t_param) * np.sin(delta)
86+
trend = np.arctan2(px, py)
87+
horiz = np.sqrt(px**2 + py**2)
88+
plunge = np.arctan2(pz, horiz)
89+
colat = np.pi / 2 - plunge
90+
r = np.sqrt(2) * np.sin(colat / 2)
91+
return trend, r
92+
93+
94+
for feat, (strikes, dips) in datasets.items():
95+
# Mean great circle (bold)
96+
mean_strike = (
97+
np.degrees(np.arctan2(np.mean(np.sin(np.deg2rad(strikes))), np.mean(np.cos(np.deg2rad(strikes))))) % 360
98+
)
99+
mean_dip = np.mean(dips)
100+
trend, r = draw_great_circle(mean_strike, mean_dip)
101+
ax.plot(trend, r, color=colors[feat], alpha=gc_alpha[feat] + 0.2, linewidth=gc_lw[feat] + 0.8, zorder=2)
102+
103+
# 4 representative great circles (evenly spaced indices)
104+
indices = np.linspace(0, len(strikes) - 1, 4, dtype=int)
105+
segments = []
106+
for idx in indices:
107+
trend, r = draw_great_circle(strikes[idx], dips[idx])
108+
points = np.column_stack([trend, r])
109+
segments.append(points)
110+
lc = LineCollection(segments, colors=colors[feat], alpha=gc_alpha[feat], linewidths=gc_lw[feat], zorder=2)
111+
ax.add_collection(lc)
112+
113+
# Poles as scatter points with distinct markers per feature type
114+
for feat in colors:
115+
mask = np.array([ft == feat for ft in feature_types])
116+
ax.scatter(
117+
pole_trend_rad[mask],
118+
pole_r[mask],
119+
c=colors[feat],
120+
s=pole_sizes[feat],
121+
marker=markers[feat],
122+
edgecolors="white",
123+
linewidth=1.2,
124+
label=f"{feat} poles",
125+
zorder=5,
126+
path_effects=[pe.withStroke(linewidth=2.5, foreground="white")],
127+
)
128+
129+
# Style
130+
ax.set_rlim(0, 1)
131+
ax.set_rticks([])
132+
theta_ticks = np.arange(0, 360, 10)
133+
ax.set_xticks(np.deg2rad(theta_ticks))
134+
tick_labels = []
135+
for d in theta_ticks:
136+
if d == 0:
137+
tick_labels.append("N")
138+
elif d == 90:
139+
tick_labels.append("E")
140+
elif d == 180:
141+
tick_labels.append("S")
142+
elif d == 270:
143+
tick_labels.append("W")
144+
elif d % 30 == 0:
145+
tick_labels.append(f"{d}°")
146+
else:
147+
tick_labels.append("")
148+
ax.set_xticklabels(tick_labels, fontsize=16, fontweight="medium")
149+
150+
# Bold cardinal direction labels
151+
for label in ax.get_xticklabels():
152+
txt = label.get_text()
153+
if txt in ("N", "E", "S", "W"):
154+
label.set_fontsize(20)
155+
label.set_fontweight("bold")
156+
label.set_color("#222222")
157+
label.set_path_effects([pe.withStroke(linewidth=2, foreground="white")])
158+
ax.grid(True, alpha=0.12, linewidth=0.4, color="#888888")
159+
160+
# Primitive circle emphasis
161+
circle_theta = np.linspace(0, 2 * np.pi, 300)
162+
ax.plot(circle_theta, np.ones_like(circle_theta), color="#333333", linewidth=2.0, zorder=3)
163+
164+
# Legend positioned to avoid tick mark overlap
165+
legend = ax.legend(
166+
loc="lower left",
167+
bbox_to_anchor=(-0.02, -0.08),
168+
fontsize=16,
169+
framealpha=0.95,
170+
edgecolor="#bbbbbb",
171+
fancybox=True,
172+
markerscale=1.3,
173+
shadow=True,
174+
borderpad=0.8,
175+
)
176+
legend.get_frame().set_linewidth(0.8)
177+
178+
ax.set_title(
179+
"stereonet-equal-area · matplotlib · pyplots.ai", fontsize=24, fontweight="medium", pad=28, color="#333333"
180+
)
181+
182+
plt.tight_layout()
183+
plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="white")

0 commit comments

Comments
 (0)