|
1 | | -""" pyplots.ai |
| 1 | +"""pyplots.ai |
2 | 2 | stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection) |
3 | 3 | Library: plotnine 0.15.3 | Python 3.14.3 |
4 | 4 | Quality: 79/100 | Created: 2026-03-15 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import matplotlib |
8 | | -import matplotlib.pyplot as plt |
9 | 8 | import numpy as np |
10 | 9 | import pandas as pd |
11 | 10 | from plotnine import ( |
12 | 11 | aes, |
| 12 | + after_stat, |
13 | 13 | coord_fixed, |
14 | 14 | element_blank, |
15 | 15 | element_text, |
| 16 | + geom_density_2d, |
16 | 17 | geom_path, |
17 | 18 | geom_point, |
18 | 19 | geom_segment, |
19 | 20 | geom_text, |
20 | 21 | ggplot, |
| 22 | + guides, |
21 | 23 | labs, |
| 24 | + scale_alpha_continuous, |
22 | 25 | scale_color_manual, |
23 | 26 | scale_shape_manual, |
24 | 27 | scale_x_continuous, |
25 | 28 | scale_y_continuous, |
26 | 29 | theme, |
27 | 30 | ) |
28 | | -from scipy.ndimage import gaussian_filter |
29 | 31 |
|
30 | 32 |
|
31 | 33 | matplotlib.use("Agg") |
|
57 | 59 | pole_y = pole_r * np.cos(pole_trend) |
58 | 60 | poles_df = pd.DataFrame({"x": pole_x, "y": pole_y, "feature_type": feature_types}) |
59 | 61 |
|
60 | | -# Compute great circles (sample representative planes per type) |
| 62 | +# Compute great circles for representative planes |
61 | 63 | gc_rows = [] |
62 | 64 | gc_indices = {"Bedding": [0, 10, 20, 30], "Fault": [40, 48, 56], "Joint": [65, 75, 85]} |
63 | 65 | gc_id = 0 |
|
72 | 74 | dip_vec = np.array( |
73 | 75 | [np.sin(dip_dir_rad) * np.cos(dip_rad), np.cos(dip_dir_rad) * np.cos(dip_rad), -np.sin(dip_rad)] |
74 | 76 | ) |
75 | | - angles = np.linspace(-np.pi / 2, np.pi / 2, 181) |
76 | | - for a in angles: |
| 77 | + for a in np.linspace(-np.pi / 2, np.pi / 2, 181): |
77 | 78 | pt = np.cos(a) * dip_vec + np.sin(a) * strike_vec |
78 | 79 | if pt[2] > 0: |
79 | 80 | pt = -pt |
80 | 81 | horiz = np.sqrt(pt[0] ** 2 + pt[1] ** 2) |
81 | 82 | plunge = np.arctan2(-pt[2], horiz) |
82 | 83 | trend = np.arctan2(pt[0], pt[1]) |
83 | 84 | r = np.sqrt(2) * np.sin((np.pi / 2 - plunge) / 2) |
84 | | - x = r * np.sin(trend) |
85 | | - y = r * np.cos(trend) |
| 85 | + x, y = r * np.sin(trend), r * np.cos(trend) |
86 | 86 | if x**2 + y**2 <= r_prim**2 * 1.01: |
87 | 87 | gc_rows.append({"x": x, "y": y, "feature_type": ftype, "gc_id": f"{ftype}_{gc_id}"}) |
88 | 88 | gc_id += 1 |
|
93 | 93 | circle_angles = np.linspace(0, 2 * np.pi, 361) |
94 | 94 | prim_df = pd.DataFrame({"x": r_prim * np.cos(circle_angles), "y": r_prim * np.sin(circle_angles), "group": "primitive"}) |
95 | 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 | + |
96 | 114 | # Degree tick marks every 10 degrees |
97 | 115 | tick_rows = [] |
98 | 116 | for deg in range(0, 360, 10): |
|
118 | 136 | if deg in (0, 90, 180, 270): |
119 | 137 | continue |
120 | 138 | rad = np.radians(deg) |
121 | | - offset = r_prim * 1.07 |
| 139 | + offset = r_prim * 1.08 |
122 | 140 | deg_labels.append({"x": offset * np.sin(rad), "y": offset * np.cos(rad), "label": f"{deg}°"}) |
123 | 141 | deg_label_df = pd.DataFrame(deg_labels) |
124 | 142 |
|
125 | | -# Density contours (Kamb-style kernel density on projected pole coordinates) |
126 | | -grid_res = 200 |
127 | | -gx_lin = np.linspace(-r_prim, r_prim, grid_res) |
128 | | -gy_lin = np.linspace(-r_prim, r_prim, grid_res) |
129 | | -gx_grid, gy_grid = np.meshgrid(gx_lin, gy_lin) |
130 | | -density = np.zeros_like(gx_grid) |
131 | | - |
132 | | -sigma = 0.15 |
133 | | -for i in range(len(pole_x)): |
134 | | - dist_sq = (gx_grid - pole_x[i]) ** 2 + (gy_grid - pole_y[i]) ** 2 |
135 | | - density += np.exp(-dist_sq / (2 * sigma**2)) |
136 | | - |
137 | | -density = gaussian_filter(density, sigma=3) |
138 | | -circle_mask = gx_grid**2 + gy_grid**2 > r_prim**2 |
139 | | -density[circle_mask] = np.nan |
140 | | - |
141 | | -# Extract contour coordinates (using matplotlib for numerical contour extraction only) |
142 | | -fig_tmp, ax_tmp = plt.subplots() |
143 | | -valid_density = density[~np.isnan(density)] |
144 | | -levels = np.linspace(valid_density.max() * 0.2, valid_density.max() * 0.8, 5) |
145 | | -cs = ax_tmp.contour(gx_lin, gy_lin, density, levels=levels) |
146 | | -plt.close(fig_tmp) |
147 | | - |
148 | | -contour_rows = [] |
149 | | -contour_id = 0 |
150 | | -for level_segs in cs.allsegs: |
151 | | - for seg in level_segs: |
152 | | - if len(seg) > 0: |
153 | | - for xi, yi in seg: |
154 | | - if xi**2 + yi**2 <= r_prim**2 * 1.01: |
155 | | - contour_rows.append({"x": xi, "y": yi, "contour_id": contour_id}) |
156 | | - contour_id += 1 |
157 | | - |
158 | | -contour_df = pd.DataFrame(contour_rows) if contour_rows else pd.DataFrame({"x": [], "y": [], "contour_id": []}) |
159 | | - |
160 | | -# Colors (colorblind-safe: blue, orange, purple) |
| 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) |
161 | 154 | colors = {"Bedding": "#306998", "Fault": "#E5A023", "Joint": "#7B68A0"} |
162 | 155 | shapes = {"Bedding": "o", "Fault": "D", "Joint": "s"} |
163 | 156 |
|
164 | | -# Plot |
| 157 | +# Plot - using plotnine's geom_density_2d (stat_density_2d) for Kamb-style contouring |
165 | 158 | plot = ( |
166 | 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 |
167 | 163 | + geom_path(aes(x="x", y="y"), data=prim_df, color="#333333", size=1.2) |
| 164 | + # Tick marks |
168 | 165 | + geom_segment(aes(x="x1", y="y1", xend="x2", yend="y2"), data=tick_df, color="#333333", size=0.5) |
169 | | - + geom_path( |
170 | | - aes(x="x", y="y", group="contour_id"), data=contour_df, color="#777777", size=0.7, alpha=0.7, linetype="dashed" |
| 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" |
171 | 170 | ) |
| 171 | + + scale_alpha_continuous(range=(0.3, 0.8)) |
| 172 | + + guides(alpha=False) |
| 173 | + # Great circles |
172 | 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 |
173 | 176 | + geom_point( |
174 | 177 | aes(x="x", y="y", color="feature_type", shape="feature_type"), data=poles_df, size=3.5, alpha=0.85, stroke=0.5 |
175 | 178 | ) |
| 179 | + # Cardinal directions |
176 | 180 | + geom_text(aes(x="x", y="y", label="label"), data=dir_df, size=18, fontweight="bold", color="#222222") |
177 | | - + geom_text(aes(x="x", y="y", label="label"), data=deg_label_df, size=10, color="#555555") |
| 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 | + ) |
178 | 191 | + scale_color_manual(name="Feature Type", values=colors) |
179 | 192 | + scale_shape_manual(name="Feature Type", values=shapes) |
180 | 193 | + coord_fixed(ratio=1) |
181 | | - + scale_x_continuous(limits=(-1.8, 1.8)) |
182 | | - + scale_y_continuous(limits=(-1.8, 1.8)) |
| 194 | + + scale_x_continuous(limits=(-1.85, 1.85)) |
| 195 | + + scale_y_continuous(limits=(-1.85, 1.85)) |
183 | 196 | + labs(title="stereonet-equal-area · plotnine · pyplots.ai") |
184 | 197 | + theme( |
185 | 198 | figure_size=(12, 12), |
|
0 commit comments