Skip to content

Commit 1e170f9

Browse files
feat(plotnine): implement stereonet-equal-area (#4909)
## Implementation: `stereonet-equal-area` - plotnine Implements the **plotnine** version of `stereonet-equal-area`. **File:** `plots/stereonet-equal-area/implementations/plotnine.py` **Parent Issue:** #4576 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23121188785)* --------- 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 1f93f52 commit 1e170f9

2 files changed

Lines changed: 445 additions & 0 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
""" pyplots.ai
2+
stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection)
3+
Library: plotnine 0.15.3 | Python 3.14.3
4+
Quality: 87/100 | Created: 2026-03-15
5+
"""
6+
7+
import matplotlib
8+
import numpy as np
9+
import pandas as pd
10+
from plotnine import (
11+
aes,
12+
after_stat,
13+
coord_fixed,
14+
element_blank,
15+
element_text,
16+
geom_density_2d,
17+
geom_path,
18+
geom_point,
19+
geom_segment,
20+
geom_text,
21+
ggplot,
22+
guides,
23+
labs,
24+
scale_alpha_continuous,
25+
scale_color_manual,
26+
scale_shape_manual,
27+
scale_x_continuous,
28+
scale_y_continuous,
29+
theme,
30+
)
31+
32+
33+
matplotlib.use("Agg")
34+
35+
# Data - Geological field measurements (strike/dip for bedding, faults, joints)
36+
np.random.seed(42)
37+
38+
bedding_strike = np.random.normal(45, 12, 40)
39+
bedding_dip = np.clip(np.random.normal(30, 8, 40), 5, 85)
40+
41+
fault_strike = np.random.normal(150, 15, 25)
42+
fault_dip = np.clip(np.random.normal(65, 10, 25), 10, 88)
43+
44+
joint_strike = np.random.normal(280, 20, 35)
45+
joint_dip = np.clip(np.random.normal(75, 8, 35), 15, 89)
46+
47+
strikes = np.concatenate([bedding_strike, fault_strike, joint_strike]) % 360
48+
dips = np.concatenate([bedding_dip, fault_dip, joint_dip])
49+
feature_types = ["Bedding"] * 40 + ["Fault"] * 25 + ["Joint"] * 35
50+
51+
# Primitive circle radius for equal-area projection
52+
r_prim = np.sqrt(2)
53+
54+
# Compute poles (equal-area Schmidt projection)
55+
pole_trend = np.radians((strikes + 90) % 360)
56+
pole_plunge = np.radians(90 - dips)
57+
pole_r = np.sqrt(2) * np.sin((np.pi / 2 - pole_plunge) / 2)
58+
pole_x = pole_r * np.sin(pole_trend)
59+
pole_y = pole_r * np.cos(pole_trend)
60+
poles_df = pd.DataFrame({"x": pole_x, "y": pole_y, "feature_type": feature_types})
61+
62+
# Compute great circles for representative planes
63+
gc_rows = []
64+
gc_indices = {"Bedding": [0, 10, 20, 30], "Fault": [40, 48, 56], "Joint": [65, 75, 85]}
65+
gc_id = 0
66+
for ftype, indices in gc_indices.items():
67+
for idx in indices:
68+
if idx >= len(strikes):
69+
continue
70+
strike_rad = np.radians(strikes[idx])
71+
dip_rad = np.radians(dips[idx])
72+
strike_vec = np.array([np.sin(strike_rad), np.cos(strike_rad), 0.0])
73+
dip_dir_rad = strike_rad + np.pi / 2
74+
dip_vec = np.array(
75+
[np.sin(dip_dir_rad) * np.cos(dip_rad), np.cos(dip_dir_rad) * np.cos(dip_rad), -np.sin(dip_rad)]
76+
)
77+
for a in np.linspace(-np.pi / 2, np.pi / 2, 181):
78+
pt = np.cos(a) * dip_vec + np.sin(a) * strike_vec
79+
if pt[2] > 0:
80+
pt = -pt
81+
horiz = np.sqrt(pt[0] ** 2 + pt[1] ** 2)
82+
plunge = np.arctan2(-pt[2], horiz)
83+
trend = np.arctan2(pt[0], pt[1])
84+
r = np.sqrt(2) * np.sin((np.pi / 2 - plunge) / 2)
85+
x, y = r * np.sin(trend), r * np.cos(trend)
86+
if x**2 + y**2 <= r_prim**2 * 1.01:
87+
gc_rows.append({"x": x, "y": y, "feature_type": ftype, "gc_id": f"{ftype}_{gc_id}"})
88+
gc_id += 1
89+
90+
gc_df = pd.DataFrame(gc_rows)
91+
92+
# Stereonet grid - primitive circle
93+
circle_angles = np.linspace(0, 2 * np.pi, 361)
94+
prim_df = pd.DataFrame({"x": r_prim * np.cos(circle_angles), "y": r_prim * np.sin(circle_angles), "group": "primitive"})
95+
96+
# Equal-area net grid lines (small circles at 30° dip intervals)
97+
grid_rows = []
98+
for dip_interval in range(30, 90, 30):
99+
plunge_rad = np.radians(90 - dip_interval)
100+
r_circle = np.sqrt(2) * np.sin((np.pi / 2 - plunge_rad) / 2)
101+
for angle in np.linspace(0, 2 * np.pi, 181):
102+
gx = r_circle * np.cos(angle)
103+
gy = r_circle * np.sin(angle)
104+
grid_rows.append({"x": gx, "y": gy, "grid_id": f"dip_{dip_interval}"})
105+
106+
# Radial lines at 30° azimuth intervals
107+
for az in range(0, 360, 30):
108+
az_rad = np.radians(az)
109+
for t in np.linspace(0, r_prim, 50):
110+
grid_rows.append({"x": t * np.sin(az_rad), "y": t * np.cos(az_rad), "grid_id": f"az_{az}"})
111+
112+
grid_df = pd.DataFrame(grid_rows)
113+
114+
# Degree tick marks every 10 degrees
115+
tick_rows = []
116+
for deg in range(0, 360, 10):
117+
rad = np.radians(deg)
118+
inner = r_prim * 0.97
119+
outer = r_prim * 1.0
120+
tick_rows.append(
121+
{"x1": inner * np.sin(rad), "y1": inner * np.cos(rad), "x2": outer * np.sin(rad), "y2": outer * np.cos(rad)}
122+
)
123+
tick_df = pd.DataFrame(tick_rows)
124+
125+
# Cardinal direction labels
126+
dir_labels = []
127+
for deg, label in [(0, "N"), (90, "E"), (180, "S"), (270, "W")]:
128+
rad = np.radians(deg)
129+
offset = r_prim * 1.12
130+
dir_labels.append({"x": offset * np.sin(rad), "y": offset * np.cos(rad), "label": label})
131+
dir_df = pd.DataFrame(dir_labels)
132+
133+
# Degree labels every 30 degrees (excluding cardinal directions)
134+
deg_labels = []
135+
for deg in range(0, 360, 30):
136+
if deg in (0, 90, 180, 270):
137+
continue
138+
rad = np.radians(deg)
139+
offset = r_prim * 1.08
140+
deg_labels.append({"x": offset * np.sin(rad), "y": offset * np.cos(rad), "label": f"{deg}°"})
141+
deg_label_df = pd.DataFrame(deg_labels)
142+
143+
# Cluster centroid annotations for data storytelling
144+
annotations = []
145+
for ftype in ["Bedding", "Fault", "Joint"]:
146+
mask = poles_df["feature_type"] == ftype
147+
cx, cy = poles_df.loc[mask, "x"].mean(), poles_df.loc[mask, "y"].mean()
148+
mean_strike = strikes[np.array(feature_types) == ftype].mean()
149+
mean_dip = dips[np.array(feature_types) == ftype].mean()
150+
annotations.append({"x": cx, "y": cy - 0.12, "feature_type": ftype, "label": f"{mean_strike:.0f}°/{mean_dip:.0f}°"})
151+
annot_df = pd.DataFrame(annotations)
152+
153+
# Colors (colorblind-safe)
154+
colors = {"Bedding": "#306998", "Fault": "#E5A023", "Joint": "#7B68A0"}
155+
shapes = {"Bedding": "o", "Fault": "D", "Joint": "s"}
156+
157+
# Plot - using plotnine's geom_density_2d (stat_density_2d) for Kamb-style contouring
158+
plot = (
159+
ggplot()
160+
# Grid lines (subtle equal-area net)
161+
+ geom_path(aes(x="x", y="y", group="grid_id"), data=grid_df, color="#CCCCCC", size=0.3, alpha=0.5)
162+
# Primitive circle
163+
+ geom_path(aes(x="x", y="y"), data=prim_df, color="#333333", size=1.2)
164+
# Tick marks
165+
+ geom_segment(aes(x="x1", y="y1", xend="x2", yend="y2"), data=tick_df, color="#333333", size=0.5)
166+
# Density contours using plotnine's native stat_density_2d via geom_density_2d
167+
# after_stat maps computed density level to alpha for visual depth
168+
+ geom_density_2d(
169+
aes(x="x", y="y", alpha=after_stat("level")), data=poles_df, color="#666666", size=0.6, linetype="dashed"
170+
)
171+
+ scale_alpha_continuous(range=(0.3, 0.8))
172+
+ guides(alpha=False)
173+
# Great circles
174+
+ geom_path(aes(x="x", y="y", color="feature_type", group="gc_id"), data=gc_df, size=0.9, alpha=0.7)
175+
# Poles to planes
176+
+ geom_point(
177+
aes(x="x", y="y", color="feature_type", shape="feature_type"), data=poles_df, size=3.5, alpha=0.85, stroke=0.5
178+
)
179+
# Cardinal directions
180+
+ geom_text(aes(x="x", y="y", label="label"), data=dir_df, size=18, fontweight="bold", color="#222222")
181+
# Degree labels (increased size for readability)
182+
+ geom_text(aes(x="x", y="y", label="label"), data=deg_label_df, size=13, color="#444444")
183+
# Cluster orientation annotations
184+
+ geom_text(
185+
aes(x="x", y="y", label="label", color="feature_type"),
186+
data=annot_df,
187+
size=11,
188+
fontstyle="italic",
189+
show_legend=False,
190+
)
191+
+ scale_color_manual(name="Feature Type", values=colors)
192+
+ scale_shape_manual(name="Feature Type", values=shapes)
193+
+ coord_fixed(ratio=1)
194+
+ scale_x_continuous(limits=(-1.85, 1.85))
195+
+ scale_y_continuous(limits=(-1.85, 1.85))
196+
+ labs(title="stereonet-equal-area · plotnine · pyplots.ai")
197+
+ theme(
198+
figure_size=(12, 12),
199+
plot_title=element_text(size=24, ha="center"),
200+
axis_title=element_blank(),
201+
axis_text=element_blank(),
202+
axis_ticks=element_blank(),
203+
axis_line=element_blank(),
204+
panel_grid_major=element_blank(),
205+
panel_grid_minor=element_blank(),
206+
panel_background=element_blank(),
207+
plot_background=element_blank(),
208+
legend_title=element_text(size=16, weight="bold"),
209+
legend_text=element_text(size=14),
210+
legend_position="right",
211+
)
212+
)
213+
214+
# Save
215+
plot.save("plot.png", dpi=300)

0 commit comments

Comments
 (0)