|
| 1 | +""" pyplots.ai |
| 2 | +stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection) |
| 3 | +Library: plotnine 0.15.3 | Python 3.14.3 |
| 4 | +Quality: 87/100 | Created: 2026-03-15 |
| 5 | +""" |
| 6 | + |
| 7 | +import matplotlib |
| 8 | +import numpy as np |
| 9 | +import pandas as pd |
| 10 | +from plotnine import ( |
| 11 | + aes, |
| 12 | + after_stat, |
| 13 | + coord_fixed, |
| 14 | + element_blank, |
| 15 | + element_text, |
| 16 | + geom_density_2d, |
| 17 | + geom_path, |
| 18 | + geom_point, |
| 19 | + geom_segment, |
| 20 | + geom_text, |
| 21 | + ggplot, |
| 22 | + guides, |
| 23 | + labs, |
| 24 | + scale_alpha_continuous, |
| 25 | + scale_color_manual, |
| 26 | + scale_shape_manual, |
| 27 | + scale_x_continuous, |
| 28 | + scale_y_continuous, |
| 29 | + theme, |
| 30 | +) |
| 31 | + |
| 32 | + |
| 33 | +matplotlib.use("Agg") |
| 34 | + |
| 35 | +# Data - Geological field measurements (strike/dip for bedding, faults, joints) |
| 36 | +np.random.seed(42) |
| 37 | + |
| 38 | +bedding_strike = np.random.normal(45, 12, 40) |
| 39 | +bedding_dip = np.clip(np.random.normal(30, 8, 40), 5, 85) |
| 40 | + |
| 41 | +fault_strike = np.random.normal(150, 15, 25) |
| 42 | +fault_dip = np.clip(np.random.normal(65, 10, 25), 10, 88) |
| 43 | + |
| 44 | +joint_strike = np.random.normal(280, 20, 35) |
| 45 | +joint_dip = np.clip(np.random.normal(75, 8, 35), 15, 89) |
| 46 | + |
| 47 | +strikes = np.concatenate([bedding_strike, fault_strike, joint_strike]) % 360 |
| 48 | +dips = np.concatenate([bedding_dip, fault_dip, joint_dip]) |
| 49 | +feature_types = ["Bedding"] * 40 + ["Fault"] * 25 + ["Joint"] * 35 |
| 50 | + |
| 51 | +# Primitive circle radius for equal-area projection |
| 52 | +r_prim = np.sqrt(2) |
| 53 | + |
| 54 | +# Compute poles (equal-area Schmidt projection) |
| 55 | +pole_trend = np.radians((strikes + 90) % 360) |
| 56 | +pole_plunge = np.radians(90 - dips) |
| 57 | +pole_r = np.sqrt(2) * np.sin((np.pi / 2 - pole_plunge) / 2) |
| 58 | +pole_x = pole_r * np.sin(pole_trend) |
| 59 | +pole_y = pole_r * np.cos(pole_trend) |
| 60 | +poles_df = pd.DataFrame({"x": pole_x, "y": pole_y, "feature_type": feature_types}) |
| 61 | + |
| 62 | +# Compute great circles for representative planes |
| 63 | +gc_rows = [] |
| 64 | +gc_indices = {"Bedding": [0, 10, 20, 30], "Fault": [40, 48, 56], "Joint": [65, 75, 85]} |
| 65 | +gc_id = 0 |
| 66 | +for ftype, indices in gc_indices.items(): |
| 67 | + for idx in indices: |
| 68 | + if idx >= len(strikes): |
| 69 | + continue |
| 70 | + strike_rad = np.radians(strikes[idx]) |
| 71 | + dip_rad = np.radians(dips[idx]) |
| 72 | + strike_vec = np.array([np.sin(strike_rad), np.cos(strike_rad), 0.0]) |
| 73 | + dip_dir_rad = strike_rad + np.pi / 2 |
| 74 | + dip_vec = np.array( |
| 75 | + [np.sin(dip_dir_rad) * np.cos(dip_rad), np.cos(dip_dir_rad) * np.cos(dip_rad), -np.sin(dip_rad)] |
| 76 | + ) |
| 77 | + for a in np.linspace(-np.pi / 2, np.pi / 2, 181): |
| 78 | + pt = np.cos(a) * dip_vec + np.sin(a) * strike_vec |
| 79 | + if pt[2] > 0: |
| 80 | + pt = -pt |
| 81 | + horiz = np.sqrt(pt[0] ** 2 + pt[1] ** 2) |
| 82 | + plunge = np.arctan2(-pt[2], horiz) |
| 83 | + trend = np.arctan2(pt[0], pt[1]) |
| 84 | + r = np.sqrt(2) * np.sin((np.pi / 2 - plunge) / 2) |
| 85 | + x, y = r * np.sin(trend), r * np.cos(trend) |
| 86 | + if x**2 + y**2 <= r_prim**2 * 1.01: |
| 87 | + gc_rows.append({"x": x, "y": y, "feature_type": ftype, "gc_id": f"{ftype}_{gc_id}"}) |
| 88 | + gc_id += 1 |
| 89 | + |
| 90 | +gc_df = pd.DataFrame(gc_rows) |
| 91 | + |
| 92 | +# Stereonet grid - primitive circle |
| 93 | +circle_angles = np.linspace(0, 2 * np.pi, 361) |
| 94 | +prim_df = pd.DataFrame({"x": r_prim * np.cos(circle_angles), "y": r_prim * np.sin(circle_angles), "group": "primitive"}) |
| 95 | + |
| 96 | +# Equal-area net grid lines (small circles at 30° dip intervals) |
| 97 | +grid_rows = [] |
| 98 | +for dip_interval in range(30, 90, 30): |
| 99 | + plunge_rad = np.radians(90 - dip_interval) |
| 100 | + r_circle = np.sqrt(2) * np.sin((np.pi / 2 - plunge_rad) / 2) |
| 101 | + for angle in np.linspace(0, 2 * np.pi, 181): |
| 102 | + gx = r_circle * np.cos(angle) |
| 103 | + gy = r_circle * np.sin(angle) |
| 104 | + grid_rows.append({"x": gx, "y": gy, "grid_id": f"dip_{dip_interval}"}) |
| 105 | + |
| 106 | +# Radial lines at 30° azimuth intervals |
| 107 | +for az in range(0, 360, 30): |
| 108 | + az_rad = np.radians(az) |
| 109 | + for t in np.linspace(0, r_prim, 50): |
| 110 | + grid_rows.append({"x": t * np.sin(az_rad), "y": t * np.cos(az_rad), "grid_id": f"az_{az}"}) |
| 111 | + |
| 112 | +grid_df = pd.DataFrame(grid_rows) |
| 113 | + |
| 114 | +# Degree tick marks every 10 degrees |
| 115 | +tick_rows = [] |
| 116 | +for deg in range(0, 360, 10): |
| 117 | + rad = np.radians(deg) |
| 118 | + inner = r_prim * 0.97 |
| 119 | + outer = r_prim * 1.0 |
| 120 | + tick_rows.append( |
| 121 | + {"x1": inner * np.sin(rad), "y1": inner * np.cos(rad), "x2": outer * np.sin(rad), "y2": outer * np.cos(rad)} |
| 122 | + ) |
| 123 | +tick_df = pd.DataFrame(tick_rows) |
| 124 | + |
| 125 | +# Cardinal direction labels |
| 126 | +dir_labels = [] |
| 127 | +for deg, label in [(0, "N"), (90, "E"), (180, "S"), (270, "W")]: |
| 128 | + rad = np.radians(deg) |
| 129 | + offset = r_prim * 1.12 |
| 130 | + dir_labels.append({"x": offset * np.sin(rad), "y": offset * np.cos(rad), "label": label}) |
| 131 | +dir_df = pd.DataFrame(dir_labels) |
| 132 | + |
| 133 | +# Degree labels every 30 degrees (excluding cardinal directions) |
| 134 | +deg_labels = [] |
| 135 | +for deg in range(0, 360, 30): |
| 136 | + if deg in (0, 90, 180, 270): |
| 137 | + continue |
| 138 | + rad = np.radians(deg) |
| 139 | + offset = r_prim * 1.08 |
| 140 | + deg_labels.append({"x": offset * np.sin(rad), "y": offset * np.cos(rad), "label": f"{deg}°"}) |
| 141 | +deg_label_df = pd.DataFrame(deg_labels) |
| 142 | + |
| 143 | +# Cluster centroid annotations for data storytelling |
| 144 | +annotations = [] |
| 145 | +for ftype in ["Bedding", "Fault", "Joint"]: |
| 146 | + mask = poles_df["feature_type"] == ftype |
| 147 | + cx, cy = poles_df.loc[mask, "x"].mean(), poles_df.loc[mask, "y"].mean() |
| 148 | + mean_strike = strikes[np.array(feature_types) == ftype].mean() |
| 149 | + mean_dip = dips[np.array(feature_types) == ftype].mean() |
| 150 | + annotations.append({"x": cx, "y": cy - 0.12, "feature_type": ftype, "label": f"{mean_strike:.0f}°/{mean_dip:.0f}°"}) |
| 151 | +annot_df = pd.DataFrame(annotations) |
| 152 | + |
| 153 | +# Colors (colorblind-safe) |
| 154 | +colors = {"Bedding": "#306998", "Fault": "#E5A023", "Joint": "#7B68A0"} |
| 155 | +shapes = {"Bedding": "o", "Fault": "D", "Joint": "s"} |
| 156 | + |
| 157 | +# Plot - using plotnine's geom_density_2d (stat_density_2d) for Kamb-style contouring |
| 158 | +plot = ( |
| 159 | + ggplot() |
| 160 | + # Grid lines (subtle equal-area net) |
| 161 | + + geom_path(aes(x="x", y="y", group="grid_id"), data=grid_df, color="#CCCCCC", size=0.3, alpha=0.5) |
| 162 | + # Primitive circle |
| 163 | + + geom_path(aes(x="x", y="y"), data=prim_df, color="#333333", size=1.2) |
| 164 | + # Tick marks |
| 165 | + + geom_segment(aes(x="x1", y="y1", xend="x2", yend="y2"), data=tick_df, color="#333333", size=0.5) |
| 166 | + # Density contours using plotnine's native stat_density_2d via geom_density_2d |
| 167 | + # after_stat maps computed density level to alpha for visual depth |
| 168 | + + geom_density_2d( |
| 169 | + aes(x="x", y="y", alpha=after_stat("level")), data=poles_df, color="#666666", size=0.6, linetype="dashed" |
| 170 | + ) |
| 171 | + + scale_alpha_continuous(range=(0.3, 0.8)) |
| 172 | + + guides(alpha=False) |
| 173 | + # Great circles |
| 174 | + + geom_path(aes(x="x", y="y", color="feature_type", group="gc_id"), data=gc_df, size=0.9, alpha=0.7) |
| 175 | + # Poles to planes |
| 176 | + + geom_point( |
| 177 | + aes(x="x", y="y", color="feature_type", shape="feature_type"), data=poles_df, size=3.5, alpha=0.85, stroke=0.5 |
| 178 | + ) |
| 179 | + # Cardinal directions |
| 180 | + + geom_text(aes(x="x", y="y", label="label"), data=dir_df, size=18, fontweight="bold", color="#222222") |
| 181 | + # Degree labels (increased size for readability) |
| 182 | + + geom_text(aes(x="x", y="y", label="label"), data=deg_label_df, size=13, color="#444444") |
| 183 | + # Cluster orientation annotations |
| 184 | + + geom_text( |
| 185 | + aes(x="x", y="y", label="label", color="feature_type"), |
| 186 | + data=annot_df, |
| 187 | + size=11, |
| 188 | + fontstyle="italic", |
| 189 | + show_legend=False, |
| 190 | + ) |
| 191 | + + scale_color_manual(name="Feature Type", values=colors) |
| 192 | + + scale_shape_manual(name="Feature Type", values=shapes) |
| 193 | + + coord_fixed(ratio=1) |
| 194 | + + scale_x_continuous(limits=(-1.85, 1.85)) |
| 195 | + + scale_y_continuous(limits=(-1.85, 1.85)) |
| 196 | + + labs(title="stereonet-equal-area · plotnine · pyplots.ai") |
| 197 | + + theme( |
| 198 | + figure_size=(12, 12), |
| 199 | + plot_title=element_text(size=24, ha="center"), |
| 200 | + axis_title=element_blank(), |
| 201 | + axis_text=element_blank(), |
| 202 | + axis_ticks=element_blank(), |
| 203 | + axis_line=element_blank(), |
| 204 | + panel_grid_major=element_blank(), |
| 205 | + panel_grid_minor=element_blank(), |
| 206 | + panel_background=element_blank(), |
| 207 | + plot_background=element_blank(), |
| 208 | + legend_title=element_text(size=16, weight="bold"), |
| 209 | + legend_text=element_text(size=14), |
| 210 | + legend_position="right", |
| 211 | + ) |
| 212 | +) |
| 213 | + |
| 214 | +# Save |
| 215 | +plot.save("plot.png", dpi=300) |
0 commit comments