|
| 1 | +"""pyplots.ai |
| 2 | +stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection) |
| 3 | +Library: plotnine | Python 3.13 |
| 4 | +Quality: pending | Created: 2026-03-15 |
| 5 | +""" |
| 6 | + |
| 7 | +import matplotlib |
| 8 | +import matplotlib.pyplot as plt |
| 9 | +import numpy as np |
| 10 | +import pandas as pd |
| 11 | +from plotnine import ( |
| 12 | + aes, |
| 13 | + coord_fixed, |
| 14 | + element_blank, |
| 15 | + element_text, |
| 16 | + geom_path, |
| 17 | + geom_point, |
| 18 | + geom_segment, |
| 19 | + geom_text, |
| 20 | + ggplot, |
| 21 | + labs, |
| 22 | + scale_color_manual, |
| 23 | + scale_x_continuous, |
| 24 | + scale_y_continuous, |
| 25 | + theme, |
| 26 | +) |
| 27 | +from scipy.ndimage import gaussian_filter |
| 28 | + |
| 29 | + |
| 30 | +matplotlib.use("Agg") |
| 31 | + |
| 32 | +# Data - Geological field measurements (strike/dip for bedding, faults, joints) |
| 33 | +np.random.seed(42) |
| 34 | + |
| 35 | +bedding_strike = np.random.normal(45, 12, 40) |
| 36 | +bedding_dip = np.clip(np.random.normal(30, 8, 40), 5, 85) |
| 37 | + |
| 38 | +fault_strike = np.random.normal(150, 15, 25) |
| 39 | +fault_dip = np.clip(np.random.normal(65, 10, 25), 10, 88) |
| 40 | + |
| 41 | +joint_strike = np.random.normal(280, 20, 35) |
| 42 | +joint_dip = np.clip(np.random.normal(75, 8, 35), 15, 89) |
| 43 | + |
| 44 | +strikes = np.concatenate([bedding_strike, fault_strike, joint_strike]) % 360 |
| 45 | +dips = np.concatenate([bedding_dip, fault_dip, joint_dip]) |
| 46 | +feature_types = ["Bedding"] * 40 + ["Fault"] * 25 + ["Joint"] * 35 |
| 47 | + |
| 48 | +# Primitive circle radius for equal-area projection |
| 49 | +r_prim = np.sqrt(2) |
| 50 | + |
| 51 | +# Compute poles (equal-area Schmidt projection) |
| 52 | +pole_trend = np.radians((strikes + 90) % 360) |
| 53 | +pole_plunge = np.radians(90 - dips) |
| 54 | +pole_r = np.sqrt(2) * np.sin((np.pi / 2 - pole_plunge) / 2) |
| 55 | +pole_x = pole_r * np.sin(pole_trend) |
| 56 | +pole_y = pole_r * np.cos(pole_trend) |
| 57 | +poles_df = pd.DataFrame({"x": pole_x, "y": pole_y, "feature_type": feature_types}) |
| 58 | + |
| 59 | +# Compute great circles (sample representative planes per type) |
| 60 | +gc_rows = [] |
| 61 | +gc_indices = {"Bedding": [0, 10, 20, 30], "Fault": [40, 48, 56], "Joint": [65, 75, 85]} |
| 62 | +gc_id = 0 |
| 63 | +for ftype, indices in gc_indices.items(): |
| 64 | + for idx in indices: |
| 65 | + if idx >= len(strikes): |
| 66 | + continue |
| 67 | + strike_rad = np.radians(strikes[idx]) |
| 68 | + dip_rad = np.radians(dips[idx]) |
| 69 | + strike_vec = np.array([np.sin(strike_rad), np.cos(strike_rad), 0.0]) |
| 70 | + dip_dir_rad = strike_rad + np.pi / 2 |
| 71 | + dip_vec = np.array( |
| 72 | + [np.sin(dip_dir_rad) * np.cos(dip_rad), np.cos(dip_dir_rad) * np.cos(dip_rad), -np.sin(dip_rad)] |
| 73 | + ) |
| 74 | + angles = np.linspace(-np.pi / 2, np.pi / 2, 181) |
| 75 | + for a in angles: |
| 76 | + pt = np.cos(a) * dip_vec + np.sin(a) * strike_vec |
| 77 | + if pt[2] > 0: |
| 78 | + pt = -pt |
| 79 | + horiz = np.sqrt(pt[0] ** 2 + pt[1] ** 2) |
| 80 | + plunge = np.arctan2(-pt[2], horiz) |
| 81 | + trend = np.arctan2(pt[0], pt[1]) |
| 82 | + r = np.sqrt(2) * np.sin((np.pi / 2 - plunge) / 2) |
| 83 | + x = r * np.sin(trend) |
| 84 | + y = r * np.cos(trend) |
| 85 | + if x**2 + y**2 <= r_prim**2 * 1.01: |
| 86 | + gc_rows.append({"x": x, "y": y, "feature_type": ftype, "gc_id": f"{ftype}_{gc_id}"}) |
| 87 | + gc_id += 1 |
| 88 | + |
| 89 | +gc_df = pd.DataFrame(gc_rows) |
| 90 | + |
| 91 | +# Stereonet grid - primitive circle |
| 92 | +circle_angles = np.linspace(0, 2 * np.pi, 361) |
| 93 | +prim_df = pd.DataFrame({"x": r_prim * np.cos(circle_angles), "y": r_prim * np.sin(circle_angles), "group": "primitive"}) |
| 94 | + |
| 95 | +# Degree tick marks every 10 degrees |
| 96 | +tick_rows = [] |
| 97 | +for deg in range(0, 360, 10): |
| 98 | + rad = np.radians(deg) |
| 99 | + inner = r_prim * 0.97 |
| 100 | + outer = r_prim * 1.0 |
| 101 | + tick_rows.append( |
| 102 | + {"x1": inner * np.sin(rad), "y1": inner * np.cos(rad), "x2": outer * np.sin(rad), "y2": outer * np.cos(rad)} |
| 103 | + ) |
| 104 | +tick_df = pd.DataFrame(tick_rows) |
| 105 | + |
| 106 | +# Cardinal direction labels |
| 107 | +dir_labels = [] |
| 108 | +for deg, label in [(0, "N"), (90, "E"), (180, "S"), (270, "W")]: |
| 109 | + rad = np.radians(deg) |
| 110 | + offset = r_prim * 1.08 |
| 111 | + dir_labels.append({"x": offset * np.sin(rad), "y": offset * np.cos(rad), "label": label}) |
| 112 | +dir_df = pd.DataFrame(dir_labels) |
| 113 | + |
| 114 | +# Density contours (Kamb-style kernel density on projected pole coordinates) |
| 115 | +grid_res = 200 |
| 116 | +gx_lin = np.linspace(-r_prim, r_prim, grid_res) |
| 117 | +gy_lin = np.linspace(-r_prim, r_prim, grid_res) |
| 118 | +gx_grid, gy_grid = np.meshgrid(gx_lin, gy_lin) |
| 119 | +density = np.zeros_like(gx_grid) |
| 120 | + |
| 121 | +sigma = 0.15 |
| 122 | +for i in range(len(pole_x)): |
| 123 | + dist_sq = (gx_grid - pole_x[i]) ** 2 + (gy_grid - pole_y[i]) ** 2 |
| 124 | + density += np.exp(-dist_sq / (2 * sigma**2)) |
| 125 | + |
| 126 | +density = gaussian_filter(density, sigma=3) |
| 127 | +circle_mask = gx_grid**2 + gy_grid**2 > r_prim**2 |
| 128 | +density[circle_mask] = np.nan |
| 129 | + |
| 130 | +# Extract contour coordinates (using matplotlib for numerical contour extraction only) |
| 131 | +fig_tmp, ax_tmp = plt.subplots() |
| 132 | +valid_density = density[~np.isnan(density)] |
| 133 | +levels = np.linspace(valid_density.max() * 0.2, valid_density.max() * 0.8, 5) |
| 134 | +cs = ax_tmp.contour(gx_lin, gy_lin, density, levels=levels) |
| 135 | +plt.close(fig_tmp) |
| 136 | + |
| 137 | +contour_rows = [] |
| 138 | +contour_id = 0 |
| 139 | +for level_segs in cs.allsegs: |
| 140 | + for seg in level_segs: |
| 141 | + if len(seg) > 0: |
| 142 | + for xi, yi in seg: |
| 143 | + if xi**2 + yi**2 <= r_prim**2 * 1.01: |
| 144 | + contour_rows.append({"x": xi, "y": yi, "contour_id": contour_id}) |
| 145 | + contour_id += 1 |
| 146 | + |
| 147 | +contour_df = pd.DataFrame(contour_rows) if contour_rows else pd.DataFrame({"x": [], "y": [], "contour_id": []}) |
| 148 | + |
| 149 | +# Colors |
| 150 | +colors = {"Bedding": "#306998", "Fault": "#C44E52", "Joint": "#55A868"} |
| 151 | + |
| 152 | +# Plot |
| 153 | +plot = ( |
| 154 | + ggplot() |
| 155 | + + geom_path(aes(x="x", y="y"), data=prim_df, color="#333333", size=1.2) |
| 156 | + + geom_segment(aes(x="x1", y="y1", xend="x2", yend="y2"), data=tick_df, color="#333333", size=0.5) |
| 157 | + + geom_path( |
| 158 | + aes(x="x", y="y", group="contour_id"), data=contour_df, color="#999999", size=0.5, alpha=0.5, linetype="dashed" |
| 159 | + ) |
| 160 | + + geom_path(aes(x="x", y="y", color="feature_type", group="gc_id"), data=gc_df, size=0.8, alpha=0.7) |
| 161 | + + geom_point(aes(x="x", y="y", color="feature_type"), data=poles_df, size=3, alpha=0.8, stroke=0.5) |
| 162 | + + geom_text(aes(x="x", y="y", label="label"), data=dir_df, size=16, fontweight="bold", color="#333333") |
| 163 | + + scale_color_manual(name="Feature Type", values=colors) |
| 164 | + + coord_fixed(ratio=1) |
| 165 | + + scale_x_continuous(limits=(-1.7, 1.7)) |
| 166 | + + scale_y_continuous(limits=(-1.7, 1.7)) |
| 167 | + + labs(title="stereonet-equal-area · plotnine · pyplots.ai") |
| 168 | + + theme( |
| 169 | + figure_size=(12, 12), |
| 170 | + plot_title=element_text(size=24, ha="center"), |
| 171 | + axis_title=element_blank(), |
| 172 | + axis_text=element_blank(), |
| 173 | + axis_ticks=element_blank(), |
| 174 | + axis_line=element_blank(), |
| 175 | + panel_grid_major=element_blank(), |
| 176 | + panel_grid_minor=element_blank(), |
| 177 | + panel_background=element_blank(), |
| 178 | + plot_background=element_blank(), |
| 179 | + legend_title=element_text(size=16), |
| 180 | + legend_text=element_text(size=14), |
| 181 | + legend_position="right", |
| 182 | + ) |
| 183 | +) |
| 184 | + |
| 185 | +# Save |
| 186 | +plot.save("plot.png", dpi=300) |
0 commit comments