|
1 | | -""" pyplots.ai |
| 1 | +"""pyplots.ai |
2 | 2 | stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection) |
3 | 3 | Library: pygal 3.1.0 | Python 3.14.3 |
4 | 4 | Quality: 68/100 | Created: 2026-03-15 |
|
29 | 29 |
|
30 | 30 | # Equal-area (Schmidt net) lower-hemisphere projection |
31 | 31 | # Pole to plane: trend = strike + 90°, plunge = 90° - dip |
32 | | -# Projection radius: r = √2 · sin(dip_rad / 2) |
| 32 | +# Projection radius: r = √2 · sin(plunge_rad / 2) where plunge = 90 - dip for poles |
| 33 | +# For great circles: r = √2 · sin((90 - plunge_of_line) / 2) |
33 | 34 | # Cartesian: x = r · sin(trend_rad), y = r · cos(trend_rad) |
34 | 35 |
|
35 | | -# Primitive circle (outer boundary representing the horizontal plane) |
| 36 | +# Primitive circle (outer boundary) |
36 | 37 | theta_circle = np.linspace(0, 2 * np.pi, 361) |
37 | 38 | circle_points = [(round(float(np.sin(t)), 4), round(float(np.cos(t)), 4)) for t in theta_circle] |
38 | 39 |
|
39 | 40 | # Tick marks every 10° around perimeter |
40 | 41 | tick_points = [] |
41 | 42 | for deg in range(0, 360, 10): |
42 | 43 | rad = np.radians(deg) |
43 | | - inner, outer = 0.97, 1.03 |
| 44 | + inner, outer = 0.96, 1.04 |
44 | 45 | tick_points.append((round(inner * np.sin(rad), 4), round(inner * np.cos(rad), 4))) |
45 | 46 | tick_points.append((round(outer * np.sin(rad), 4), round(outer * np.cos(rad), 4))) |
46 | 47 | tick_points.append(None) |
47 | 48 |
|
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) |
| 49 | +# Equal-area net grid lines (subtle, as spec requires) |
| 50 | +# Small circles at dip intervals of 30° (at 30°, 60°) |
| 51 | +grid_points = [] |
| 52 | +for dip_grid in [30, 60]: |
| 53 | + r_grid = np.sqrt(2) * np.sin(np.radians(dip_grid / 2)) |
| 54 | + theta_grid = np.linspace(0, 2 * np.pi, 181) |
| 55 | + for t in theta_grid: |
| 56 | + grid_points.append((round(float(r_grid * np.sin(t)), 4), round(float(r_grid * np.cos(t)), 4))) |
| 57 | + grid_points.append(None) |
| 58 | + |
| 59 | +# Diametral lines every 30° (great circle diameters through center) |
| 60 | +for az in range(0, 180, 30): |
| 61 | + rad = np.radians(az) |
| 62 | + grid_points.append((round(-np.sin(rad), 4), round(-np.cos(rad), 4))) |
| 63 | + grid_points.append((round(np.sin(rad), 4), round(np.cos(rad), 4))) |
| 64 | + grid_points.append(None) |
| 65 | + |
| 66 | +# Compass direction labels as dot patterns |
| 67 | +# N label at top - letter shape using connected line segments |
| 68 | +n_label = [] |
| 69 | +n_cx, n_cy = 0.0, 1.15 |
| 70 | +n_s = 0.03 |
| 71 | +for t in np.linspace(0, 1, 10): |
| 72 | + n_label.append((round(n_cx - n_s, 4), round(n_cy - n_s * 2 + t * n_s * 4, 4))) |
| 73 | +n_label.append(None) |
| 74 | +for t in np.linspace(0, 1, 10): |
| 75 | + n_label.append((round(n_cx - n_s + t * n_s * 2, 4), round(n_cy + n_s * 2 - t * n_s * 4, 4))) |
| 76 | +n_label.append(None) |
| 77 | +for t in np.linspace(0, 1, 10): |
| 78 | + n_label.append((round(n_cx + n_s, 4), round(n_cy - n_s * 2 + t * n_s * 4, 4))) |
| 79 | + |
57 | 80 |
|
58 | 81 | # 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 | 82 | alphas = np.linspace(0, np.pi, 91) |
63 | 83 |
|
64 | 84 | feature_sets = [ |
|
69 | 89 |
|
70 | 90 | gc_series = {} |
71 | 91 | pole_series = {} |
| 92 | +all_pole_x = [] |
| 93 | +all_pole_y = [] |
72 | 94 |
|
73 | 95 | for name, strikes, dips in feature_sets: |
74 | 96 | # Great circles with None separators between each plane |
|
91 | 113 | pole_x = pole_r * np.sin(pole_trend_rad) |
92 | 114 | pole_y = pole_r * np.cos(pole_trend_rad) |
93 | 115 | pole_series[name] = [(round(float(xi), 4), round(float(yi), 4)) for xi, yi in zip(pole_x, pole_y, strict=True)] |
| 116 | + all_pole_x.extend(pole_x) |
| 117 | + all_pole_y.extend(pole_y) |
| 118 | + |
| 119 | +# Kamb density contours on all pole data |
| 120 | +# Grid-based density estimation in projected space |
| 121 | +all_pole_x = np.array(all_pole_x) |
| 122 | +all_pole_y = np.array(all_pole_y) |
| 123 | + |
| 124 | +grid_res = 60 |
| 125 | +gx = np.linspace(-1, 1, grid_res) |
| 126 | +gy = np.linspace(-1, 1, grid_res) |
| 127 | +gxx, gyy = np.meshgrid(gx, gy) |
| 128 | + |
| 129 | +# Gaussian kernel density on projected coordinates |
| 130 | +sigma = 0.12 |
| 131 | +density = np.zeros_like(gxx) |
| 132 | +for px, py in zip(all_pole_x, all_pole_y, strict=True): |
| 133 | + density += np.exp(-((gxx - px) ** 2 + (gyy - py) ** 2) / (2 * sigma**2)) |
| 134 | + |
| 135 | +# Mask outside the primitive circle |
| 136 | +mask = gxx**2 + gyy**2 > 1.0 |
| 137 | +density[mask] = 0 |
94 | 138 |
|
95 | | -# Style |
| 139 | +# Extract contour lines using a simple marching approach |
| 140 | +# Generate contour polylines at specific density levels |
| 141 | +contour_levels = [2.0, 4.0, 6.0, 8.0] |
| 142 | +max_density = density.max() |
| 143 | +contour_series = [] |
| 144 | + |
| 145 | +for level in contour_levels: |
| 146 | + if level >= max_density: |
| 147 | + continue |
| 148 | + level_points = [] |
| 149 | + # Scan horizontal edges for contour crossings |
| 150 | + for i in range(grid_res - 1): |
| 151 | + for j in range(grid_res): |
| 152 | + v0, v1 = density[j, i], density[j, i + 1] |
| 153 | + if (v0 - level) * (v1 - level) < 0 and v0 != v1: |
| 154 | + frac = (level - v0) / (v1 - v0) |
| 155 | + cx = gx[i] + frac * (gx[i + 1] - gx[i]) |
| 156 | + cy = gy[j] |
| 157 | + if cx**2 + cy**2 <= 1.0: |
| 158 | + level_points.append((cx, cy)) |
| 159 | + # Scan vertical edges |
| 160 | + for i in range(grid_res): |
| 161 | + for j in range(grid_res - 1): |
| 162 | + v0, v1 = density[j, i], density[j + 1, i] |
| 163 | + if (v0 - level) * (v1 - level) < 0 and v0 != v1: |
| 164 | + frac = (level - v0) / (v1 - v0) |
| 165 | + cx = gx[i] |
| 166 | + cy = gy[j] + frac * (gy[j + 1] - gy[j]) |
| 167 | + if cx**2 + cy**2 <= 1.0: |
| 168 | + level_points.append((cx, cy)) |
| 169 | + |
| 170 | + if level_points: |
| 171 | + # Sort points by angle from each cluster centroid to form connected contours |
| 172 | + pts = np.array(level_points) |
| 173 | + # Order points by angle for smoother contour rendering |
| 174 | + angles = np.arctan2(pts[:, 0], pts[:, 1]) |
| 175 | + order = np.argsort(angles) |
| 176 | + sorted_pts = pts[order] |
| 177 | + # Split into segments when consecutive points are far apart |
| 178 | + segment = [(round(float(sorted_pts[0, 0]), 4), round(float(sorted_pts[0, 1]), 4))] |
| 179 | + for k in range(1, len(sorted_pts)): |
| 180 | + dist = np.sqrt( |
| 181 | + (sorted_pts[k, 0] - sorted_pts[k - 1, 0]) ** 2 + (sorted_pts[k, 1] - sorted_pts[k - 1, 1]) ** 2 |
| 182 | + ) |
| 183 | + if dist > 0.15: |
| 184 | + segment.append(None) |
| 185 | + segment.append((round(float(sorted_pts[k, 0]), 4), round(float(sorted_pts[k, 1]), 4))) |
| 186 | + contour_series.append(segment) |
| 187 | + |
| 188 | + |
| 189 | +# Style - colorblind-safe palette (blue, orange, amber instead of green) |
96 | 190 | custom_style = Style( |
97 | 191 | background="white", |
98 | 192 | plot_background="white", |
99 | 193 | foreground="#333333", |
100 | 194 | foreground_strong="#333333", |
101 | 195 | foreground_subtle="#eeeeee", |
102 | 196 | colors=( |
103 | | - "#aaaaaa", # primitive circle |
104 | | - "#aaaaaa", # tick marks |
105 | | - "#aaaaaa", # compass marks |
| 197 | + "#d0d0d0", # grid lines |
| 198 | + "#777777", # structural elements (circle + ticks + N) |
106 | 199 | "#306998", # bedding great circles |
107 | 200 | "#D4513D", # fault great circles |
108 | | - "#2CA02C", # joint great circles |
| 201 | + "#DAA520", # joint great circles (amber/gold, colorblind-safe) |
109 | 202 | "#306998", # bedding poles |
110 | 203 | "#D4513D", # fault poles |
111 | | - "#2CA02C", # joint poles |
| 204 | + "#DAA520", # joint poles (amber/gold) |
| 205 | + "#666666", # contour level 1 |
| 206 | + "#555555", # contour level 2 |
| 207 | + "#444444", # contour level 3 |
| 208 | + "#333333", # contour level 4 |
112 | 209 | ), |
113 | 210 | title_font_size=56, |
114 | 211 | label_font_size=1, |
115 | 212 | major_label_font_size=1, |
116 | 213 | legend_font_size=36, |
117 | 214 | value_font_size=20, |
118 | 215 | tooltip_font_size=28, |
119 | | - opacity=0.5, |
| 216 | + opacity=0.6, |
120 | 217 | opacity_hover=0.8, |
121 | 218 | ) |
122 | 219 |
|
|
128 | 225 | title="stereonet-equal-area · pygal · pyplots.ai", |
129 | 226 | show_legend=True, |
130 | 227 | legend_at_bottom=True, |
131 | | - legend_at_bottom_columns=3, |
| 228 | + legend_at_bottom_columns=4, |
132 | 229 | show_x_labels=False, |
133 | 230 | show_y_labels=False, |
134 | 231 | show_x_guides=False, |
135 | 232 | show_y_guides=False, |
136 | | - xrange=(-1.35, 1.35), |
137 | | - range=(-1.35, 1.35), |
| 233 | + xrange=(-1.25, 1.25), |
| 234 | + range=(-1.25, 1.25), |
138 | 235 | dots_size=0, |
139 | 236 | allow_interruptions=True, |
140 | | - margin_top=40, |
141 | | - margin_bottom=60, |
142 | | - margin_left=20, |
143 | | - margin_right=20, |
| 237 | + margin_top=30, |
| 238 | + margin_bottom=50, |
| 239 | + margin_left=10, |
| 240 | + margin_right=10, |
144 | 241 | ) |
145 | 242 |
|
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}) |
| 243 | +# Grid lines (subtle equal-area net) |
| 244 | +chart.add("", grid_points, stroke=True, show_dots=False, stroke_style={"width": 0.8, "dasharray": "4,4"}) |
| 245 | + |
| 246 | +# Combine structural elements: circle + ticks + N label |
| 247 | +structural = circle_points + [None] + tick_points + [None] + n_label |
| 248 | +chart.add("", structural, stroke=True, show_dots=False, stroke_style={"width": 2.5}) |
150 | 249 |
|
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}) |
| 250 | +# Great circles (planes) - thicker lines for visibility |
| 251 | +chart.add("Bedding (planes)", gc_series["Bedding"], stroke=True, show_dots=False, stroke_style={"width": 2.5}) |
| 252 | +chart.add("Faults (planes)", gc_series["Faults"], stroke=True, show_dots=False, stroke_style={"width": 2.5}) |
| 253 | +chart.add("Joints (planes)", gc_series["Joints"], stroke=True, show_dots=False, stroke_style={"width": 2.5}) |
155 | 254 |
|
156 | 255 | # 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) |
| 256 | +chart.add("Bedding (poles)", pole_series["Bedding"], stroke=False, show_dots=True, dots_size=14) |
| 257 | +chart.add("Faults (poles)", pole_series["Faults"], stroke=False, show_dots=True, dots_size=14) |
| 258 | +chart.add("Joints (poles)", pole_series["Joints"], stroke=False, show_dots=True, dots_size=14) |
| 259 | + |
| 260 | +# Density contours - only first gets legend label |
| 261 | +for i, contour_data in enumerate(contour_series): |
| 262 | + label = "Density contours" if i == 0 else "" |
| 263 | + chart.add(label, contour_data, stroke=True, show_dots=False, stroke_style={"width": 1.5, "dasharray": "6,3"}) |
160 | 264 |
|
161 | 265 | # Save |
162 | 266 | chart.render_to_png("plot.png") |
|
0 commit comments