|
| 1 | +"""pyplots.ai |
| 2 | +stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection) |
| 3 | +Library: letsplot | Python 3.13 |
| 4 | +Quality: pending | Created: 2026-03-15 |
| 5 | +""" |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +import pandas as pd |
| 9 | +from lets_plot import * # noqa: F403 |
| 10 | +from lets_plot.export import ggsave |
| 11 | + |
| 12 | + |
| 13 | +LetsPlot.setup_html() |
| 14 | + |
| 15 | +# Data - Field measurements from a structural geology mapping campaign |
| 16 | +np.random.seed(42) |
| 17 | + |
| 18 | +# Bedding planes (NE strike ~045, moderate dip ~35° SE) |
| 19 | +n_bedding = 25 |
| 20 | +bedding_strike = np.random.normal(45, 12, n_bedding) % 360 |
| 21 | +bedding_dip = np.clip(np.random.normal(35, 8, n_bedding), 5, 85) |
| 22 | + |
| 23 | +# Joint set 1 (N-S striking ~000, steep dip ~78° W) |
| 24 | +n_j1 = 12 |
| 25 | +j1_strike = np.random.normal(355, 10, n_j1) % 360 |
| 26 | +j1_dip = np.clip(np.random.normal(78, 7, n_j1), 10, 89) |
| 27 | + |
| 28 | +# Joint set 2 (E-W striking ~090, steep dip ~80° N) |
| 29 | +n_j2 = 10 |
| 30 | +j2_strike = np.random.normal(90, 10, n_j2) % 360 |
| 31 | +j2_dip = np.clip(np.random.normal(80, 6, n_j2), 10, 89) |
| 32 | + |
| 33 | +# Faults (NW-SE striking ~150, moderate-steep dip ~60°) |
| 34 | +n_faults = 13 |
| 35 | +faults_strike = np.random.uniform(130, 170, n_faults) |
| 36 | +faults_dip = np.clip(np.random.normal(60, 12, n_faults), 10, 89) |
| 37 | + |
| 38 | +strikes = np.concatenate([bedding_strike, j1_strike, j2_strike, faults_strike]) |
| 39 | +dips = np.concatenate([bedding_dip, j1_dip, j2_dip, faults_dip]) |
| 40 | +feature_types = ["Bedding"] * n_bedding + ["Joint"] * (n_j1 + n_j2) + ["Fault"] * n_faults |
| 41 | + |
| 42 | +# Equal-area (Schmidt net) lower-hemisphere projection |
| 43 | +strike_rad = np.radians(strikes) |
| 44 | +dip_rad = np.radians(dips) |
| 45 | +dip_dir_rad = strike_rad + np.pi / 2 |
| 46 | + |
| 47 | +# Pole 3D coordinates (lower hemisphere normal to each plane) |
| 48 | +pole_x_3d = np.sin(dip_rad) * np.sin(dip_dir_rad) |
| 49 | +pole_y_3d = np.sin(dip_rad) * np.cos(dip_dir_rad) |
| 50 | +pole_z_3d = -np.cos(dip_rad) |
| 51 | + |
| 52 | +# Lambert azimuthal equal-area projection |
| 53 | +pole_scale = 1.0 / np.sqrt(1.0 - pole_z_3d) |
| 54 | +pole_x = pole_x_3d * pole_scale |
| 55 | +pole_y = pole_y_3d * pole_scale |
| 56 | + |
| 57 | +poles_df = pd.DataFrame({"x": pole_x, "y": pole_y, "feature_type": feature_types}) |
| 58 | + |
| 59 | +# Great circle paths for each plane |
| 60 | +gc_rows = [] |
| 61 | +alphas = np.linspace(0, np.pi, 60) |
| 62 | +cos_a = np.cos(alphas) |
| 63 | +sin_a = np.sin(alphas) |
| 64 | + |
| 65 | +for i in range(len(strikes)): |
| 66 | + s = strike_rad[i] |
| 67 | + d = dip_rad[i] |
| 68 | + dd = dip_dir_rad[i] |
| 69 | + |
| 70 | + # Strike vector (horizontal) and dip vector (in the plane, pointing downdip) |
| 71 | + sv_x, sv_y = np.sin(s), np.cos(s) |
| 72 | + dv_x = np.cos(d) * np.sin(dd) |
| 73 | + dv_y = np.cos(d) * np.cos(dd) |
| 74 | + dv_z = -np.sin(d) |
| 75 | + |
| 76 | + # Parametric great circle: P(a) = cos(a)*strike_vec + sin(a)*dip_vec |
| 77 | + px = cos_a * sv_x + sin_a * dv_x |
| 78 | + py = cos_a * sv_y + sin_a * dv_y |
| 79 | + pz = sin_a * dv_z |
| 80 | + |
| 81 | + # Equal-area projection |
| 82 | + sc = 1.0 / np.sqrt(1.0 - pz) |
| 83 | + gx = px * sc |
| 84 | + gy = py * sc |
| 85 | + |
| 86 | + for j in range(len(alphas)): |
| 87 | + gc_rows.append({"x": gx[j], "y": gy[j], "group": i, "feature_type": feature_types[i]}) |
| 88 | + |
| 89 | +gc_df = pd.DataFrame(gc_rows) |
| 90 | + |
| 91 | +# Kamb-style density contours using Gaussian KDE on projected pole coordinates |
| 92 | +grid_n = 100 |
| 93 | +gx_arr = np.linspace(-1.0, 1.0, grid_n) |
| 94 | +gy_arr = np.linspace(-1.0, 1.0, grid_n) |
| 95 | +gxx, gyy = np.meshgrid(gx_arr, gy_arr) |
| 96 | +inside_circle = (gxx**2 + gyy**2) <= 1.0 |
| 97 | + |
| 98 | +# Manual Gaussian KDE |
| 99 | +n_pts = len(pole_x) |
| 100 | +bw = 0.15 |
| 101 | +density = np.zeros((grid_n, grid_n)) |
| 102 | +for k in range(n_pts): |
| 103 | + dx = gxx - pole_x[k] |
| 104 | + dy = gyy - pole_y[k] |
| 105 | + density += np.exp(-0.5 * (dx**2 + dy**2) / bw**2) |
| 106 | +density /= n_pts * 2 * np.pi * bw**2 |
| 107 | +density[~inside_circle] = np.nan |
| 108 | + |
| 109 | +# Extract contour paths manually using marching squares |
| 110 | +# Compute contour levels |
| 111 | +d_valid = density[inside_circle] |
| 112 | +d_max = np.nanmax(d_valid) |
| 113 | +d_min = np.nanmin(d_valid[d_valid > 0]) |
| 114 | +levels = np.linspace(d_min + (d_max - d_min) * 0.2, d_max * 0.85, 5) |
| 115 | + |
| 116 | +contour_rows = [] |
| 117 | +contour_id = 0 |
| 118 | +dx_step = gx_arr[1] - gx_arr[0] |
| 119 | +dy_step = gy_arr[1] - gy_arr[0] |
| 120 | + |
| 121 | +for level in levels: |
| 122 | + # Find cells where contour crosses (simple marching squares) |
| 123 | + for row in range(grid_n - 1): |
| 124 | + for col in range(grid_n - 1): |
| 125 | + corners = [density[row, col], density[row, col + 1], density[row + 1, col + 1], density[row + 1, col]] |
| 126 | + if any(np.isnan(c) for c in corners): |
| 127 | + continue |
| 128 | + above = [c >= level for c in corners] |
| 129 | + if all(above) or not any(above): |
| 130 | + continue |
| 131 | + |
| 132 | + # Find intersection points on edges |
| 133 | + edges = [] |
| 134 | + edge_pairs = [(0, 1), (1, 2), (2, 3), (3, 0)] |
| 135 | + corner_coords = [ |
| 136 | + (gx_arr[col], gy_arr[row]), |
| 137 | + (gx_arr[col + 1], gy_arr[row]), |
| 138 | + (gx_arr[col + 1], gy_arr[row + 1]), |
| 139 | + (gx_arr[col], gy_arr[row + 1]), |
| 140 | + ] |
| 141 | + for ci, cj in edge_pairs: |
| 142 | + if above[ci] != above[cj]: |
| 143 | + t = (level - corners[ci]) / (corners[cj] - corners[ci]) |
| 144 | + ex = corner_coords[ci][0] + t * (corner_coords[cj][0] - corner_coords[ci][0]) |
| 145 | + ey = corner_coords[ci][1] + t * (corner_coords[cj][1] - corner_coords[ci][1]) |
| 146 | + if ex**2 + ey**2 <= 1.02: |
| 147 | + edges.append((ex, ey)) |
| 148 | + |
| 149 | + if len(edges) >= 2: |
| 150 | + contour_rows.append({"x": edges[0][0], "y": edges[0][1], "group": contour_id}) |
| 151 | + contour_rows.append({"x": edges[1][0], "y": edges[1][1], "group": contour_id}) |
| 152 | + contour_id += 1 |
| 153 | + |
| 154 | +contour_df = pd.DataFrame(contour_rows) |
| 155 | + |
| 156 | +# Primitive circle (outer boundary) |
| 157 | +theta = np.linspace(0, 2 * np.pi, 200) |
| 158 | +circle_df = pd.DataFrame({"x": np.cos(theta), "y": np.sin(theta)}) |
| 159 | + |
| 160 | +# Tick marks every 10° (longer every 30°) |
| 161 | +tick_rows = [] |
| 162 | +for deg in range(0, 360, 10): |
| 163 | + rad = np.radians(deg) |
| 164 | + inner_r = 0.95 if deg % 30 != 0 else 0.92 |
| 165 | + tick_rows.append({"x": np.sin(rad) * inner_r, "y": np.cos(rad) * inner_r, "xend": np.sin(rad), "yend": np.cos(rad)}) |
| 166 | + |
| 167 | +tick_df = pd.DataFrame(tick_rows) |
| 168 | + |
| 169 | +# Reference cross lines (N-S, E-W) |
| 170 | +ref_df = pd.DataFrame( |
| 171 | + [{"x": 0, "y": -1, "xend": 0, "yend": 1, "group": 0}, {"x": -1, "y": 0, "xend": 1, "yend": 0, "group": 1}] |
| 172 | +) |
| 173 | + |
| 174 | +# Cardinal direction labels |
| 175 | +label_df = pd.DataFrame({"x": [0, 1.09, 0, -1.09], "y": [1.09, 0, -1.09, 0], "label": ["N", "E", "S", "W"]}) |
| 176 | + |
| 177 | +# Colorblind-safe palette |
| 178 | +color_values = ["#306998", "#CC79A7", "#E69F00"] |
| 179 | + |
| 180 | +# Plot |
| 181 | +plot = ( |
| 182 | + ggplot() |
| 183 | + # Reference cross lines |
| 184 | + + geom_segment( |
| 185 | + aes(x="x", y="y", xend="xend", yend="yend"), data=ref_df, color="#DDDDDD", size=0.5, linetype="dashed" |
| 186 | + ) |
| 187 | + # Density contour segments (Kamb-style) |
| 188 | + + geom_line(aes(x="x", y="y", group="group"), data=contour_df, color="#999999", size=0.5, alpha=0.5) |
| 189 | + # Great circles |
| 190 | + + geom_path(aes(x="x", y="y", group="group", color="feature_type"), data=gc_df, size=0.5, alpha=0.35) |
| 191 | + # Poles to planes |
| 192 | + + geom_point(aes(x="x", y="y", color="feature_type"), data=poles_df, size=5, alpha=0.85) |
| 193 | + # Primitive circle |
| 194 | + + geom_path(aes(x="x", y="y"), data=circle_df, color="#333333", size=1.2) |
| 195 | + # Tick marks |
| 196 | + + geom_segment(aes(x="x", y="y", xend="xend", yend="yend"), data=tick_df, color="#333333", size=0.7) |
| 197 | + # Cardinal labels |
| 198 | + + geom_text(aes(x="x", y="y", label="label"), data=label_df, size=18, color="#333333", fontface="bold") |
| 199 | + # Color scale |
| 200 | + + scale_color_manual(values=color_values, name="Feature Type") |
| 201 | + # Fixed aspect ratio |
| 202 | + + coord_fixed() |
| 203 | + + scale_x_continuous(limits=[-1.25, 1.25]) |
| 204 | + + scale_y_continuous(limits=[-1.25, 1.25]) |
| 205 | + # Title |
| 206 | + + labs(title="stereonet-equal-area · letsplot · pyplots.ai") |
| 207 | + # Theme |
| 208 | + + theme( |
| 209 | + plot_title=element_text(size=24, hjust=0.5), |
| 210 | + legend_title=element_text(size=18), |
| 211 | + legend_text=element_text(size=16), |
| 212 | + legend_position="right", |
| 213 | + axis_title=element_blank(), |
| 214 | + axis_text=element_blank(), |
| 215 | + axis_ticks=element_blank(), |
| 216 | + axis_line=element_blank(), |
| 217 | + panel_grid=element_blank(), |
| 218 | + ) |
| 219 | + + ggsize(1200, 1200) |
| 220 | +) |
| 221 | + |
| 222 | +# Save |
| 223 | +ggsave(plot, "plot.png", path=".", scale=3) |
| 224 | +ggsave(plot, "plot.html", path=".") |
0 commit comments