Skip to content

Commit 69ef26c

Browse files
feat(pygal): implement stereonet-equal-area
1 parent 4a54060 commit 69ef26c

1 file changed

Lines changed: 163 additions & 0 deletions

File tree

  • plots/stereonet-equal-area/implementations
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""pyplots.ai
2+
stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection)
3+
Library: pygal | Python 3.13
4+
Quality: pending | Created: 2026-03-15
5+
"""
6+
7+
import numpy as np
8+
import pygal
9+
from pygal.style import Style
10+
11+
12+
# Data - Field measurements from a geological mapping campaign
13+
np.random.seed(42)
14+
15+
# Bedding planes - consistent NE strike (~040°), moderate SE dip (~30°)
16+
n_bedding = 25
17+
bedding_strike = np.random.normal(40, 8, n_bedding) % 360
18+
bedding_dip = np.clip(np.random.normal(30, 5, n_bedding), 5, 85)
19+
20+
# Fault planes - steeper, striking ESE (~120°), steep dip (~65°)
21+
n_faults = 15
22+
fault_strike = np.random.normal(120, 12, n_faults) % 360
23+
fault_dip = np.clip(np.random.normal(65, 8, n_faults), 10, 89)
24+
25+
# Joint set - sub-vertical (~80°), striking roughly N-S (~350°)
26+
n_joints = 20
27+
joint_strike = np.random.normal(350, 10, n_joints) % 360
28+
joint_dip = np.clip(np.random.normal(80, 6, n_joints), 15, 89)
29+
30+
# Equal-area (Schmidt net) lower-hemisphere projection
31+
# Pole to plane: trend = strike + 90°, plunge = 90° - dip
32+
# Projection radius: r = √2 · sin(dip_rad / 2)
33+
# Cartesian: x = r · sin(trend_rad), y = r · cos(trend_rad)
34+
35+
# Primitive circle (outer boundary representing the horizontal plane)
36+
theta_circle = np.linspace(0, 2 * np.pi, 361)
37+
circle_points = [(round(float(np.sin(t)), 4), round(float(np.cos(t)), 4)) for t in theta_circle]
38+
39+
# Tick marks every 10° around perimeter
40+
tick_points = []
41+
for deg in range(0, 360, 10):
42+
rad = np.radians(deg)
43+
inner, outer = 0.97, 1.03
44+
tick_points.append((round(inner * np.sin(rad), 4), round(inner * np.cos(rad), 4)))
45+
tick_points.append((round(outer * np.sin(rad), 4), round(outer * np.cos(rad), 4)))
46+
tick_points.append(None)
47+
48+
# Compass markers (N, E, S, W as small cross marks beyond the circle)
49+
compass_points = []
50+
for _bearing, offset_x, offset_y in [(0, 0, 1.12), (90, 1.12, 0), (180, 0, -1.12), (270, -1.12, 0)]:
51+
compass_points.append((round(offset_x - 0.02, 4), round(offset_y, 4)))
52+
compass_points.append((round(offset_x + 0.02, 4), round(offset_y, 4)))
53+
compass_points.append(None)
54+
compass_points.append((round(offset_x, 4), round(offset_y - 0.02, 4)))
55+
compass_points.append((round(offset_x, 4), round(offset_y + 0.02, 4)))
56+
compass_points.append(None)
57+
58+
# Great circle computation for all planes
59+
# For rake angle α (0 to π), a line in the plane has:
60+
# plunge = arcsin(sin(dip) · sin(α))
61+
# trend = strike + atan2(sin(α) · cos(dip), cos(α))
62+
alphas = np.linspace(0, np.pi, 91)
63+
64+
feature_sets = [
65+
("Bedding", bedding_strike, bedding_dip),
66+
("Faults", fault_strike, fault_dip),
67+
("Joints", joint_strike, joint_dip),
68+
]
69+
70+
gc_series = {}
71+
pole_series = {}
72+
73+
for name, strikes, dips in feature_sets:
74+
# Great circles with None separators between each plane
75+
gc_data = []
76+
for i in range(len(strikes)):
77+
d_rad = np.radians(dips[i])
78+
plunges = np.degrees(np.arcsin(np.sin(d_rad) * np.sin(alphas)))
79+
trends = strikes[i] + np.degrees(np.arctan2(np.sin(alphas) * np.cos(d_rad), np.cos(alphas)))
80+
r = np.sqrt(2) * np.sin(np.radians((90 - plunges) / 2))
81+
x = r * np.sin(np.radians(trends))
82+
y = r * np.cos(np.radians(trends))
83+
if gc_data:
84+
gc_data.append(None)
85+
gc_data.extend([(round(float(xi), 4), round(float(yi), 4)) for xi, yi in zip(x, y, strict=True)])
86+
gc_series[name] = gc_data
87+
88+
# Poles to planes
89+
pole_trend_rad = np.radians((strikes + 90) % 360)
90+
pole_r = np.sqrt(2) * np.sin(np.radians(dips / 2))
91+
pole_x = pole_r * np.sin(pole_trend_rad)
92+
pole_y = pole_r * np.cos(pole_trend_rad)
93+
pole_series[name] = [(round(float(xi), 4), round(float(yi), 4)) for xi, yi in zip(pole_x, pole_y, strict=True)]
94+
95+
# Style
96+
custom_style = Style(
97+
background="white",
98+
plot_background="white",
99+
foreground="#333333",
100+
foreground_strong="#333333",
101+
foreground_subtle="#eeeeee",
102+
colors=(
103+
"#aaaaaa", # primitive circle
104+
"#aaaaaa", # tick marks
105+
"#aaaaaa", # compass marks
106+
"#306998", # bedding great circles
107+
"#D4513D", # fault great circles
108+
"#2CA02C", # joint great circles
109+
"#306998", # bedding poles
110+
"#D4513D", # fault poles
111+
"#2CA02C", # joint poles
112+
),
113+
title_font_size=56,
114+
label_font_size=1,
115+
major_label_font_size=1,
116+
legend_font_size=36,
117+
value_font_size=20,
118+
tooltip_font_size=28,
119+
opacity=0.5,
120+
opacity_hover=0.8,
121+
)
122+
123+
# Chart
124+
chart = pygal.XY(
125+
width=3600,
126+
height=3600,
127+
style=custom_style,
128+
title="stereonet-equal-area · pygal · pyplots.ai",
129+
show_legend=True,
130+
legend_at_bottom=True,
131+
legend_at_bottom_columns=3,
132+
show_x_labels=False,
133+
show_y_labels=False,
134+
show_x_guides=False,
135+
show_y_guides=False,
136+
xrange=(-1.35, 1.35),
137+
range=(-1.35, 1.35),
138+
dots_size=0,
139+
allow_interruptions=True,
140+
margin_top=40,
141+
margin_bottom=60,
142+
margin_left=20,
143+
margin_right=20,
144+
)
145+
146+
# Structural elements (circle, ticks, compass)
147+
chart.add("", circle_points, stroke=True, show_dots=False, stroke_style={"width": 2.5})
148+
chart.add("", tick_points, stroke=True, show_dots=False, stroke_style={"width": 1.5})
149+
chart.add("", compass_points, stroke=True, show_dots=False, stroke_style={"width": 2})
150+
151+
# Great circles (planes)
152+
chart.add("Bedding (planes)", gc_series["Bedding"], stroke=True, show_dots=False, stroke_style={"width": 1.5})
153+
chart.add("Faults (planes)", gc_series["Faults"], stroke=True, show_dots=False, stroke_style={"width": 1.5})
154+
chart.add("Joints (planes)", gc_series["Joints"], stroke=True, show_dots=False, stroke_style={"width": 1.5})
155+
156+
# Poles to planes (scatter points)
157+
chart.add("Bedding (poles)", pole_series["Bedding"], stroke=False, show_dots=True, dots_size=12)
158+
chart.add("Fault (poles)", pole_series["Faults"], stroke=False, show_dots=True, dots_size=12)
159+
chart.add("Joint (poles)", pole_series["Joints"], stroke=False, show_dots=True, dots_size=12)
160+
161+
# Save
162+
chart.render_to_png("plot.png")
163+
chart.render_to_file("plot.html")

0 commit comments

Comments
 (0)