|
| 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