|
| 1 | +""" pyplots.ai |
| 2 | +stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection) |
| 3 | +Library: pygal 3.1.0 | Python 3.14.3 |
| 4 | +Quality: 78/100 | 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(plunge_rad / 2) where plunge = 90 - dip for poles |
| 33 | +# For great circles: r = √2 · sin((90 - plunge_of_line) / 2) |
| 34 | +# Cartesian: x = r · sin(trend_rad), y = r · cos(trend_rad) |
| 35 | + |
| 36 | + |
| 37 | +def clip_to_circle(x, y): |
| 38 | + """Clip point to be within the primitive circle (r <= 1).""" |
| 39 | + r = np.sqrt(x**2 + y**2) |
| 40 | + if r > 1.0: |
| 41 | + x, y = x / r, y / r |
| 42 | + return x, y |
| 43 | + |
| 44 | + |
| 45 | +# Primitive circle + tick marks + N label combined as one series |
| 46 | +structural_points = [] |
| 47 | + |
| 48 | +# Primitive circle (outer boundary) |
| 49 | +theta_circle = np.linspace(0, 2 * np.pi, 361) |
| 50 | +for t in theta_circle: |
| 51 | + structural_points.append((round(float(np.sin(t)), 4), round(float(np.cos(t)), 4))) |
| 52 | +structural_points.append(None) |
| 53 | + |
| 54 | +# Tick marks every 10° around perimeter |
| 55 | +for deg in range(0, 360, 10): |
| 56 | + rad = np.radians(deg) |
| 57 | + inner, outer = 0.96, 1.04 |
| 58 | + structural_points.append((round(inner * np.sin(rad), 4), round(inner * np.cos(rad), 4))) |
| 59 | + structural_points.append((round(outer * np.sin(rad), 4), round(outer * np.cos(rad), 4))) |
| 60 | + structural_points.append(None) |
| 61 | + |
| 62 | +# N label at top - letter shape using connected line segments |
| 63 | +n_cx, n_cy = 0.0, 1.14 |
| 64 | +n_s = 0.035 |
| 65 | +for t in np.linspace(0, 1, 10): |
| 66 | + structural_points.append((round(n_cx - n_s, 4), round(n_cy - n_s * 2.5 + t * n_s * 5, 4))) |
| 67 | +structural_points.append(None) |
| 68 | +for t in np.linspace(0, 1, 10): |
| 69 | + structural_points.append((round(n_cx - n_s + t * n_s * 2, 4), round(n_cy + n_s * 2.5 - t * n_s * 5, 4))) |
| 70 | +structural_points.append(None) |
| 71 | +for t in np.linspace(0, 1, 10): |
| 72 | + structural_points.append((round(n_cx + n_s, 4), round(n_cy - n_s * 2.5 + t * n_s * 5, 4))) |
| 73 | + |
| 74 | +# Equal-area net grid lines (subtle, as spec requires) |
| 75 | +grid_points = [] |
| 76 | +# Small circles at dip intervals of 30° (at 30°, 60°) |
| 77 | +for dip_grid in [30, 60]: |
| 78 | + r_grid = np.sqrt(2) * np.sin(np.radians(dip_grid / 2)) |
| 79 | + theta_grid = np.linspace(0, 2 * np.pi, 181) |
| 80 | + for t in theta_grid: |
| 81 | + grid_points.append((round(float(r_grid * np.sin(t)), 4), round(float(r_grid * np.cos(t)), 4))) |
| 82 | + grid_points.append(None) |
| 83 | + |
| 84 | +# Diametral lines every 30° (great circle diameters through center) |
| 85 | +for az in range(0, 180, 30): |
| 86 | + rad = np.radians(az) |
| 87 | + grid_points.append((round(-np.sin(rad), 4), round(-np.cos(rad), 4))) |
| 88 | + grid_points.append((round(np.sin(rad), 4), round(np.cos(rad), 4))) |
| 89 | + grid_points.append(None) |
| 90 | + |
| 91 | + |
| 92 | +# Great circle computation for all planes |
| 93 | +alphas = np.linspace(0, np.pi, 91) |
| 94 | + |
| 95 | +feature_sets = [ |
| 96 | + ("Bedding", bedding_strike, bedding_dip), |
| 97 | + ("Faults", fault_strike, fault_dip), |
| 98 | + ("Joints", joint_strike, joint_dip), |
| 99 | +] |
| 100 | + |
| 101 | +# Visual hierarchy: bedding is primary feature (thickest), faults secondary, joints tertiary |
| 102 | +gc_widths = {"Bedding": 3.0, "Faults": 2.2, "Joints": 1.8} |
| 103 | +pole_sizes = {"Bedding": 16, "Faults": 12, "Joints": 10} |
| 104 | + |
| 105 | +gc_series = {} |
| 106 | +pole_series = {} |
| 107 | +all_pole_x = [] |
| 108 | +all_pole_y = [] |
| 109 | + |
| 110 | +for name, strikes, dips in feature_sets: |
| 111 | + # Great circles with None separators between each plane |
| 112 | + gc_data = [] |
| 113 | + for i in range(len(strikes)): |
| 114 | + d_rad = np.radians(dips[i]) |
| 115 | + plunges = np.degrees(np.arcsin(np.sin(d_rad) * np.sin(alphas))) |
| 116 | + trends = strikes[i] + np.degrees(np.arctan2(np.sin(alphas) * np.cos(d_rad), np.cos(alphas))) |
| 117 | + r = np.sqrt(2) * np.sin(np.radians((90 - plunges) / 2)) |
| 118 | + x = r * np.sin(np.radians(trends)) |
| 119 | + y = r * np.cos(np.radians(trends)) |
| 120 | + if gc_data: |
| 121 | + gc_data.append(None) |
| 122 | + for xi, yi in zip(x, y, strict=True): |
| 123 | + cx, cy = clip_to_circle(float(xi), float(yi)) |
| 124 | + gc_data.append((round(cx, 4), round(cy, 4))) |
| 125 | + gc_series[name] = gc_data |
| 126 | + |
| 127 | + # Poles to planes |
| 128 | + pole_trend_rad = np.radians((strikes + 90) % 360) |
| 129 | + pole_r = np.sqrt(2) * np.sin(np.radians(dips / 2)) |
| 130 | + pole_x = pole_r * np.sin(pole_trend_rad) |
| 131 | + pole_y = pole_r * np.cos(pole_trend_rad) |
| 132 | + pole_series[name] = [(round(float(xi), 4), round(float(yi), 4)) for xi, yi in zip(pole_x, pole_y, strict=True)] |
| 133 | + all_pole_x.extend(pole_x) |
| 134 | + all_pole_y.extend(pole_y) |
| 135 | + |
| 136 | +# Kamb density contours on all pole data |
| 137 | +all_pole_x = np.array(all_pole_x) |
| 138 | +all_pole_y = np.array(all_pole_y) |
| 139 | + |
| 140 | +grid_res = 80 |
| 141 | +gx = np.linspace(-1, 1, grid_res) |
| 142 | +gy = np.linspace(-1, 1, grid_res) |
| 143 | +gxx, gyy = np.meshgrid(gx, gy) |
| 144 | +dx = gx[1] - gx[0] |
| 145 | + |
| 146 | +# Gaussian kernel density on projected coordinates |
| 147 | +sigma = 0.12 |
| 148 | +density = np.zeros_like(gxx) |
| 149 | +for px, py in zip(all_pole_x, all_pole_y, strict=True): |
| 150 | + density += np.exp(-((gxx - px) ** 2 + (gyy - py) ** 2) / (2 * sigma**2)) |
| 151 | + |
| 152 | +# Mask outside the primitive circle |
| 153 | +mask = gxx**2 + gyy**2 > 1.0 |
| 154 | +density[mask] = 0 |
| 155 | + |
| 156 | + |
| 157 | +def trace_contour(density, gx, gy, level): |
| 158 | + """Trace contour lines using marching squares with proper edge following.""" |
| 159 | + ny, nx = density.shape |
| 160 | + segments = [] |
| 161 | + |
| 162 | + # Find all cell edge crossings and build segments |
| 163 | + for j in range(ny - 1): |
| 164 | + for i in range(nx - 1): |
| 165 | + # Four corners of cell: TL, TR, BR, BL |
| 166 | + vals = [density[j, i], density[j, i + 1], density[j + 1, i + 1], density[j + 1, i]] |
| 167 | + above = [v >= level for v in vals] |
| 168 | + case = above[0] * 8 + above[1] * 4 + above[2] * 2 + above[3] * 1 |
| 169 | + |
| 170 | + if case == 0 or case == 15: |
| 171 | + continue |
| 172 | + |
| 173 | + # Interpolation along edges |
| 174 | + def interp_edge(v0, v1, p0, p1): |
| 175 | + if abs(v1 - v0) < 1e-12: |
| 176 | + t = 0.5 |
| 177 | + else: |
| 178 | + t = (level - v0) / (v1 - v0) |
| 179 | + return (p0[0] + t * (p1[0] - p0[0]), p0[1] + t * (p1[1] - p0[1])) |
| 180 | + |
| 181 | + corners = [(gx[i], gy[j]), (gx[i + 1], gy[j]), (gx[i + 1], gy[j + 1]), (gx[i], gy[j + 1])] |
| 182 | + |
| 183 | + edges = {} |
| 184 | + # Top edge (0-1) |
| 185 | + if above[0] != above[1]: |
| 186 | + edges["top"] = interp_edge(vals[0], vals[1], corners[0], corners[1]) |
| 187 | + # Right edge (1-2) |
| 188 | + if above[1] != above[2]: |
| 189 | + edges["right"] = interp_edge(vals[1], vals[2], corners[1], corners[2]) |
| 190 | + # Bottom edge (2-3) |
| 191 | + if above[2] != above[3]: |
| 192 | + edges["bottom"] = interp_edge(vals[2], vals[3], corners[2], corners[3]) |
| 193 | + # Left edge (3-0) |
| 194 | + if above[3] != above[0]: |
| 195 | + edges["left"] = interp_edge(vals[3], vals[0], corners[3], corners[0]) |
| 196 | + |
| 197 | + edge_keys = list(edges.keys()) |
| 198 | + if len(edge_keys) == 2: |
| 199 | + p0, p1 = edges[edge_keys[0]], edges[edge_keys[1]] |
| 200 | + if p0[0] ** 2 + p0[1] ** 2 <= 1.02 and p1[0] ** 2 + p1[1] ** 2 <= 1.02: |
| 201 | + segments.append((p0, p1)) |
| 202 | + elif len(edge_keys) == 4: |
| 203 | + # Saddle point - connect based on center value |
| 204 | + center = sum(vals) / 4 |
| 205 | + if center >= level: |
| 206 | + pairs = [("top", "right"), ("bottom", "left")] |
| 207 | + else: |
| 208 | + pairs = [("top", "left"), ("bottom", "right")] |
| 209 | + for k0, k1 in pairs: |
| 210 | + p0, p1 = edges[k0], edges[k1] |
| 211 | + if p0[0] ** 2 + p0[1] ** 2 <= 1.02 and p1[0] ** 2 + p1[1] ** 2 <= 1.02: |
| 212 | + segments.append((p0, p1)) |
| 213 | + |
| 214 | + # Chain segments into polylines |
| 215 | + if not segments: |
| 216 | + return [] |
| 217 | + |
| 218 | + polylines = [] |
| 219 | + used = [False] * len(segments) |
| 220 | + eps = dx * 1.5 |
| 221 | + |
| 222 | + for start_idx in range(len(segments)): |
| 223 | + if used[start_idx]: |
| 224 | + continue |
| 225 | + used[start_idx] = True |
| 226 | + chain = [segments[start_idx][0], segments[start_idx][1]] |
| 227 | + |
| 228 | + # Extend forward |
| 229 | + changed = True |
| 230 | + while changed: |
| 231 | + changed = False |
| 232 | + for k in range(len(segments)): |
| 233 | + if used[k]: |
| 234 | + continue |
| 235 | + s = segments[k] |
| 236 | + d0 = (chain[-1][0] - s[0][0]) ** 2 + (chain[-1][1] - s[0][1]) ** 2 |
| 237 | + d1 = (chain[-1][0] - s[1][0]) ** 2 + (chain[-1][1] - s[1][1]) ** 2 |
| 238 | + if d0 < eps**2: |
| 239 | + chain.append(s[1]) |
| 240 | + used[k] = True |
| 241 | + changed = True |
| 242 | + break |
| 243 | + elif d1 < eps**2: |
| 244 | + chain.append(s[0]) |
| 245 | + used[k] = True |
| 246 | + changed = True |
| 247 | + break |
| 248 | + |
| 249 | + # Extend backward |
| 250 | + changed = True |
| 251 | + while changed: |
| 252 | + changed = False |
| 253 | + for k in range(len(segments)): |
| 254 | + if used[k]: |
| 255 | + continue |
| 256 | + s = segments[k] |
| 257 | + d0 = (chain[0][0] - s[0][0]) ** 2 + (chain[0][1] - s[0][1]) ** 2 |
| 258 | + d1 = (chain[0][0] - s[1][0]) ** 2 + (chain[0][1] - s[1][1]) ** 2 |
| 259 | + if d0 < eps**2: |
| 260 | + chain.insert(0, s[1]) |
| 261 | + used[k] = True |
| 262 | + changed = True |
| 263 | + break |
| 264 | + elif d1 < eps**2: |
| 265 | + chain.insert(0, s[0]) |
| 266 | + used[k] = True |
| 267 | + changed = True |
| 268 | + break |
| 269 | + |
| 270 | + if len(chain) >= 3: |
| 271 | + polylines.append(chain) |
| 272 | + |
| 273 | + return polylines |
| 274 | + |
| 275 | + |
| 276 | +# Extract contour polylines at density levels |
| 277 | +contour_levels = [2.0, 4.0, 6.0, 8.0] |
| 278 | +max_density = density.max() |
| 279 | +all_contour_points = [] |
| 280 | + |
| 281 | +for level in contour_levels: |
| 282 | + if level >= max_density: |
| 283 | + continue |
| 284 | + polylines = trace_contour(density, gx, gy, level) |
| 285 | + for polyline in polylines: |
| 286 | + if all_contour_points: |
| 287 | + all_contour_points.append(None) |
| 288 | + for pt in polyline: |
| 289 | + cx, cy = clip_to_circle(pt[0], pt[1]) |
| 290 | + all_contour_points.append((round(cx, 4), round(cy, 4))) |
| 291 | + |
| 292 | + |
| 293 | +# Style - colorblind-safe palette (blue, red, amber) |
| 294 | +custom_style = Style( |
| 295 | + background="white", |
| 296 | + plot_background="white", |
| 297 | + foreground="#333333", |
| 298 | + foreground_strong="#333333", |
| 299 | + foreground_subtle="#eeeeee", |
| 300 | + colors=( |
| 301 | + "#306998", # bedding great circles (blue - primary) |
| 302 | + "#D4513D", # fault great circles (red) |
| 303 | + "#DAA520", # joint great circles (amber/gold) |
| 304 | + "#306998", # bedding poles |
| 305 | + "#D4513D", # fault poles |
| 306 | + "#DAA520", # joint poles |
| 307 | + "#888888", # density contours |
| 308 | + "#bbbbbb", # grid lines |
| 309 | + "#555555", # stereonet boundary |
| 310 | + ), |
| 311 | + title_font_size=56, |
| 312 | + label_font_size=1, |
| 313 | + major_label_font_size=1, |
| 314 | + legend_font_size=36, |
| 315 | + value_font_size=20, |
| 316 | + tooltip_font_size=28, |
| 317 | + opacity=0.7, |
| 318 | + opacity_hover=0.9, |
| 319 | +) |
| 320 | + |
| 321 | +# Chart |
| 322 | +chart = pygal.XY( |
| 323 | + width=3600, |
| 324 | + height=3600, |
| 325 | + style=custom_style, |
| 326 | + title="stereonet-equal-area \u00b7 pygal \u00b7 pyplots.ai", |
| 327 | + show_legend=True, |
| 328 | + legend_at_bottom=True, |
| 329 | + legend_at_bottom_columns=4, |
| 330 | + show_x_labels=False, |
| 331 | + show_y_labels=False, |
| 332 | + show_x_guides=False, |
| 333 | + show_y_guides=False, |
| 334 | + xrange=(-1.25, 1.25), |
| 335 | + range=(-1.25, 1.25), |
| 336 | + dots_size=0, |
| 337 | + allow_interruptions=True, |
| 338 | + margin_top=30, |
| 339 | + margin_bottom=50, |
| 340 | + margin_left=10, |
| 341 | + margin_right=10, |
| 342 | +) |
| 343 | + |
| 344 | +# Great circles (planes) - visual hierarchy via line weight |
| 345 | +chart.add( |
| 346 | + "Bedding (planes)", gc_series["Bedding"], stroke=True, show_dots=False, stroke_style={"width": gc_widths["Bedding"]} |
| 347 | +) |
| 348 | +chart.add( |
| 349 | + "Faults (planes)", gc_series["Faults"], stroke=True, show_dots=False, stroke_style={"width": gc_widths["Faults"]} |
| 350 | +) |
| 351 | +chart.add( |
| 352 | + "Joints (planes)", gc_series["Joints"], stroke=True, show_dots=False, stroke_style={"width": gc_widths["Joints"]} |
| 353 | +) |
| 354 | + |
| 355 | +# Poles to planes (scatter points) - visual hierarchy via dot size |
| 356 | +chart.add("Bedding (poles)", pole_series["Bedding"], stroke=False, show_dots=True, dots_size=pole_sizes["Bedding"]) |
| 357 | +chart.add("Faults (poles)", pole_series["Faults"], stroke=False, show_dots=True, dots_size=pole_sizes["Faults"]) |
| 358 | +chart.add("Joints (poles)", pole_series["Joints"], stroke=False, show_dots=True, dots_size=pole_sizes["Joints"]) |
| 359 | + |
| 360 | +# Density contours - single combined series |
| 361 | +if all_contour_points: |
| 362 | + chart.add( |
| 363 | + "Density contours", |
| 364 | + all_contour_points, |
| 365 | + stroke=True, |
| 366 | + show_dots=False, |
| 367 | + stroke_style={"width": 1.8, "dasharray": "6,3"}, |
| 368 | + ) |
| 369 | + |
| 370 | +# Grid lines (subtle equal-area net) - named to avoid orphan legend entry |
| 371 | +chart.add("Grid", grid_points, stroke=True, show_dots=False, stroke_style={"width": 0.8, "dasharray": "4,4"}) |
| 372 | + |
| 373 | +# Stereonet boundary: primitive circle + tick marks + N label |
| 374 | +chart.add("Stereonet", structural_points, stroke=True, show_dots=False, stroke_style={"width": 2.5}) |
| 375 | + |
| 376 | +# Save |
| 377 | +chart.render_to_png("plot.png") |
| 378 | +chart.render_to_file("plot.html") |
0 commit comments