|
1 | | -""" pyplots.ai |
| 1 | +"""pyplots.ai |
2 | 2 | column-stratigraphic: Stratigraphic Column with Lithology Patterns |
3 | 3 | Library: plotnine 0.15.3 | Python 3.14.3 |
4 | | -Quality: 81/100 | Created: 2026-03-15 |
5 | 4 | """ |
6 | 5 |
|
7 | 6 | import numpy as np |
8 | 7 | import pandas as pd |
9 | 8 | from plotnine import ( |
10 | 9 | aes, |
| 10 | + annotate, |
11 | 11 | element_blank, |
12 | 12 | element_line, |
13 | 13 | element_rect, |
|
17 | 17 | geom_segment, |
18 | 18 | geom_text, |
19 | 19 | ggplot, |
| 20 | + guide_legend, |
| 21 | + guides, |
20 | 22 | labs, |
21 | 23 | scale_fill_manual, |
22 | 24 | scale_x_continuous, |
|
26 | 28 | ) |
27 | 29 |
|
28 | 30 |
|
29 | | -# Data - synthetic sedimentary borehole section |
| 31 | +# Data - synthetic sedimentary borehole section (Western US) |
30 | 32 | layers = pd.DataFrame( |
31 | 33 | { |
32 | 34 | "top": [0, 12, 28, 45, 58, 72, 95, 115, 138, 160], |
|
76 | 78 | # Column position constants |
77 | 79 | col_left = 0.0 |
78 | 80 | col_right = 4.0 |
79 | | -col_mid = 2.0 |
80 | 81 |
|
81 | | -# Lithology colors (geologically conventional tones) |
| 82 | +# Lithology colors - improved contrast between siltstone and shale |
82 | 83 | lith_colors = { |
83 | 84 | "Sandstone": "#F5D76E", |
84 | | - "Shale": "#8E8E8E", |
| 85 | + "Shale": "#7A7A7A", |
85 | 86 | "Limestone": "#6DAEDB", |
86 | | - "Siltstone": "#B5C7D3", |
| 87 | + "Siltstone": "#C8DCC0", |
87 | 88 | "Conglomerate": "#D4785C", |
88 | 89 | "Mudstone": "#7B6B5E", |
89 | 90 | } |
|
95 | 96 | rect_df["ymin"] = rect_df["top"] |
96 | 97 | rect_df["ymax"] = rect_df["bottom"] |
97 | 98 |
|
| 99 | +# Highlight the unconformity between Kootenai Fm and Morrison Fm (J/K boundary) |
| 100 | +# This adds storytelling: a major geological event |
| 101 | +unconformity_depth = 95.0 |
| 102 | + |
98 | 103 | # Generate pattern overlay data for each lithology |
99 | 104 | pattern_rows = [] |
100 | 105 | np.random.seed(42) |
|
106 | 111 | thickness = bot_val - top_val |
107 | 112 |
|
108 | 113 | if lith == "Sandstone": |
109 | | - # Stipple dots pattern |
110 | | - n_dots = int(thickness * 3) |
| 114 | + # Stipple dots pattern - denser |
| 115 | + n_dots = int(thickness * 4) |
111 | 116 | for _ in range(n_dots): |
112 | 117 | px = np.random.uniform(col_left + 0.3, col_right - 0.3) |
113 | 118 | py = np.random.uniform(top_val + 0.5, bot_val - 0.5) |
114 | 119 | pattern_rows.append({"x": px, "y": py, "xend": px, "yend": py, "ptype": "dot", "lithology": lith}) |
115 | 120 |
|
116 | 121 | elif lith == "Shale": |
117 | 122 | # Horizontal dashes |
118 | | - spacing = 3.0 |
119 | | - y_pos = top_val + 1.5 |
120 | | - while y_pos < bot_val - 1.0: |
| 123 | + spacing = 2.5 |
| 124 | + y_pos = top_val + 1.0 |
| 125 | + while y_pos < bot_val - 0.5: |
121 | 126 | for x_start in np.arange(col_left + 0.3, col_right - 0.5, 0.8): |
122 | 127 | pattern_rows.append( |
123 | 128 | {"x": x_start, "y": y_pos, "xend": x_start + 0.5, "yend": y_pos, "ptype": "dash", "lithology": lith} |
|
130 | 135 | y_pos = top_val + 2.0 |
131 | 136 | row_idx = 0 |
132 | 137 | while y_pos < bot_val - 1.0: |
133 | | - # Horizontal line |
134 | 138 | pattern_rows.append( |
135 | 139 | { |
136 | 140 | "x": col_left + 0.2, |
|
141 | 145 | "lithology": lith, |
142 | 146 | } |
143 | 147 | ) |
144 | | - # Vertical lines (offset every other row) |
145 | 148 | offset = 1.0 if row_idx % 2 == 0 else 0.0 |
146 | 149 | for vx in np.arange(col_left + 0.5 + offset, col_right - 0.3, 2.0): |
147 | 150 | y_top = max(y_pos - spacing, top_val + 0.2) |
|
152 | 155 | row_idx += 1 |
153 | 156 |
|
154 | 157 | elif lith == "Siltstone": |
155 | | - # Short random dashes at various angles |
156 | | - n_dashes = int(thickness * 2) |
| 158 | + # Short random dashes - much denser for visibility |
| 159 | + n_dashes = int(thickness * 6) |
157 | 160 | for _ in range(n_dashes): |
158 | | - px = np.random.uniform(col_left + 0.4, col_right - 0.4) |
159 | | - py = np.random.uniform(top_val + 1.0, bot_val - 1.0) |
160 | | - dx = np.random.uniform(-0.2, 0.2) |
| 161 | + px = np.random.uniform(col_left + 0.3, col_right - 0.3) |
| 162 | + py = np.random.uniform(top_val + 0.5, bot_val - 0.5) |
| 163 | + dx = np.random.uniform(-0.15, 0.15) |
161 | 164 | pattern_rows.append( |
162 | | - {"x": px, "y": py, "xend": px + dx, "yend": py + 0.3, "ptype": "short_dash", "lithology": lith} |
| 165 | + {"x": px, "y": py, "xend": px + dx, "yend": py + 0.2, "ptype": "short_dash", "lithology": lith} |
163 | 166 | ) |
164 | 167 |
|
165 | 168 | elif lith == "Conglomerate": |
166 | 169 | # Scattered circles (represented as large dots) |
167 | | - n_circles = int(thickness * 1.5) |
| 170 | + n_circles = int(thickness * 2) |
168 | 171 | for _ in range(n_circles): |
169 | 172 | px = np.random.uniform(col_left + 0.5, col_right - 0.5) |
170 | 173 | py = np.random.uniform(top_val + 1.0, bot_val - 1.0) |
171 | 174 | pattern_rows.append({"x": px, "y": py, "xend": px, "yend": py, "ptype": "circle", "lithology": lith}) |
172 | 175 |
|
173 | 176 | elif lith == "Mudstone": |
174 | 177 | # Dense horizontal dashes (finer than shale) |
175 | | - spacing = 2.5 |
176 | | - y_pos = top_val + 1.0 |
177 | | - while y_pos < bot_val - 0.5: |
178 | | - for x_start in np.arange(col_left + 0.2, col_right - 0.3, 0.6): |
| 178 | + spacing = 2.0 |
| 179 | + y_pos = top_val + 0.8 |
| 180 | + while y_pos < bot_val - 0.3: |
| 181 | + for x_start in np.arange(col_left + 0.2, col_right - 0.3, 0.5): |
179 | 182 | pattern_rows.append( |
180 | 183 | { |
181 | 184 | "x": x_start, |
182 | 185 | "y": y_pos, |
183 | | - "xend": x_start + 0.3, |
| 186 | + "xend": x_start + 0.25, |
184 | 187 | "yend": y_pos, |
185 | 188 | "ptype": "fine_dash", |
186 | 189 | "lithology": lith, |
|
205 | 208 | age_groups["mid"] = (age_groups["top"] + age_groups["bottom"]) / 2 |
206 | 209 | age_labels = pd.DataFrame({"x": col_left - 0.3, "y": age_groups["mid"].values, "label": age_groups.index.tolist()}) |
207 | 210 |
|
| 211 | +# Age bracket lines connecting age labels to the column |
| 212 | +age_brackets = [] |
| 213 | +for _, grp in age_groups.iterrows(): |
| 214 | + age_brackets.append({"x": col_left - 0.15, "y": grp["top"], "xend": col_left - 0.15, "yend": grp["bottom"]}) |
| 215 | +age_bracket_df = pd.DataFrame(age_brackets) |
| 216 | + |
208 | 217 | # Layer boundary lines |
209 | 218 | boundaries = sorted(set(layers["top"].tolist() + layers["bottom"].tolist())) |
210 | 219 | boundary_df = pd.DataFrame( |
211 | 220 | {"x": [col_left] * len(boundaries), "xend": [col_right] * len(boundaries), "y": boundaries, "yend": boundaries} |
212 | 221 | ) |
213 | 222 |
|
| 223 | +# Unconformity wavy line data (zigzag at Jurassic/Cretaceous boundary) |
| 224 | +wavy_x = np.linspace(col_left, col_right, 40) |
| 225 | +wavy_y = unconformity_depth + np.sin(wavy_x * 8) * 0.8 |
| 226 | +wavy_df = pd.DataFrame({"x": wavy_x[:-1], "y": wavy_y[:-1], "xend": wavy_x[1:], "yend": wavy_y[1:]}) |
| 227 | + |
214 | 228 | # Plot |
215 | 229 | plot = ( |
216 | 230 | ggplot() |
|
220 | 234 | mapping=aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax", fill="lithology"), |
221 | 235 | color="#2C2C2C", |
222 | 236 | size=0.8, |
223 | | - alpha=0.55, |
| 237 | + alpha=0.6, |
224 | 238 | ) |
225 | 239 | # Pattern overlays - line segments |
226 | 240 | + geom_segment( |
227 | | - data=lines_df, mapping=aes(x="x", y="y", xend="xend", yend="yend"), color="#2C2C2C", size=0.6, alpha=0.7 |
| 241 | + data=lines_df, mapping=aes(x="x", y="y", xend="xend", yend="yend"), color="#2C2C2C", size=0.6, alpha=0.75 |
228 | 242 | ) |
229 | 243 | # Pattern overlays - dots (sandstone) |
230 | | - + geom_point(data=dots_df, mapping=aes(x="x", y="y"), color="#2C2C2C", size=0.8, alpha=0.6) |
| 244 | + + geom_point(data=dots_df, mapping=aes(x="x", y="y"), color="#2C2C2C", size=1.0, alpha=0.65) |
231 | 245 | # Pattern overlays - circles (conglomerate) |
232 | 246 | + geom_point( |
233 | 247 | data=circles_df, |
234 | 248 | mapping=aes(x="x", y="y"), |
235 | 249 | color="#2C2C2C", |
236 | | - size=3, |
237 | | - alpha=0.5, |
| 250 | + size=3.5, |
| 251 | + alpha=0.55, |
238 | 252 | shape="o", |
239 | 253 | fill="none", |
240 | | - stroke=0.8, |
| 254 | + stroke=0.9, |
241 | 255 | ) |
242 | 256 | # Layer boundary lines |
243 | 257 | + geom_segment(data=boundary_df, mapping=aes(x="x", y="y", xend="xend", yend="yend"), color="#2C2C2C", size=0.8) |
244 | | - # Formation labels (right side) |
| 258 | + # Unconformity wavy line (focal point - major geological event) |
| 259 | + + geom_segment( |
| 260 | + data=wavy_df, mapping=aes(x="x", y="y", xend="xend", yend="yend"), color="#B22222", size=1.8, alpha=0.9 |
| 261 | + ) |
| 262 | + # Unconformity annotation |
| 263 | + + annotate( |
| 264 | + "text", |
| 265 | + x=col_right + 0.3, |
| 266 | + y=unconformity_depth, |
| 267 | + label="~ Unconformity ~", |
| 268 | + ha="left", |
| 269 | + size=12, |
| 270 | + color="#B22222", |
| 271 | + fontstyle="italic", |
| 272 | + fontweight="bold", |
| 273 | + ) |
| 274 | + # Age bracket lines |
| 275 | + + geom_segment(data=age_bracket_df, mapping=aes(x="x", y="y", xend="xend", yend="yend"), color="#444444", size=0.7) |
| 276 | + # Formation labels (right side) - larger font |
245 | 277 | + geom_text( |
246 | 278 | data=form_labels, |
247 | 279 | mapping=aes(x="x", y="y", label="label"), |
248 | 280 | ha="left", |
249 | | - size=11, |
| 281 | + size=14, |
250 | 282 | fontstyle="italic", |
251 | 283 | color="#1A1A1A", |
252 | 284 | ) |
253 | | - # Age labels (left side) |
254 | | - + geom_text(data=age_labels, mapping=aes(x="x", y="y", label="label"), ha="right", size=10, color="#1A1A1A") |
| 285 | + # Age labels (left side) - larger font |
| 286 | + + geom_text( |
| 287 | + data=age_labels, |
| 288 | + mapping=aes(x="x", y="y", label="label"), |
| 289 | + ha="right", |
| 290 | + size=13, |
| 291 | + fontweight="bold", |
| 292 | + color="#333333", |
| 293 | + ) |
255 | 294 | # Scales |
256 | 295 | + scale_fill_manual(values=lith_colors) |
257 | 296 | + scale_y_reverse(name="Depth (m)", breaks=list(range(0, 200, 20))) |
258 | | - + scale_x_continuous(limits=(-3.5, 8.5), breaks=[]) |
| 297 | + + scale_x_continuous(limits=(-4.0, 9.5), breaks=[]) |
259 | 298 | # Labels |
260 | 299 | + labs(title="column-stratigraphic · plotnine · pyplots.ai", fill="Lithology", x="") |
| 300 | + # Legend styling |
| 301 | + + guides(fill=guide_legend(nrow=1)) |
261 | 302 | # Theme |
262 | 303 | + theme_minimal() |
263 | 304 | + theme( |
264 | | - figure_size=(10, 16), |
265 | | - plot_title=element_text(size=22, face="bold", ha="center"), |
266 | | - axis_title_y=element_text(size=18), |
| 305 | + figure_size=(11, 16), |
| 306 | + plot_title=element_text(size=24, face="bold", ha="center"), |
| 307 | + axis_title_y=element_text(size=20), |
267 | 308 | axis_title_x=element_blank(), |
268 | | - axis_text_y=element_text(size=14), |
| 309 | + axis_text_y=element_text(size=16), |
269 | 310 | axis_text_x=element_blank(), |
270 | 311 | axis_ticks_major_x=element_blank(), |
271 | 312 | legend_title=element_text(size=16, face="bold"), |
272 | | - legend_text=element_text(size=13), |
| 313 | + legend_text=element_text(size=14), |
273 | 314 | legend_position="bottom", |
274 | 315 | legend_direction="horizontal", |
| 316 | + legend_key_size=20, |
275 | 317 | panel_grid_major_x=element_blank(), |
276 | 318 | panel_grid_minor_x=element_blank(), |
277 | | - panel_grid_major_y=element_line(color="#E0E0E0", size=0.3, alpha=0.4), |
| 319 | + panel_grid_major_y=element_line(color="#E8E8E8", size=0.25, alpha=0.3), |
278 | 320 | panel_grid_minor_y=element_blank(), |
279 | | - panel_background=element_rect(fill="white", color="none"), |
| 321 | + panel_background=element_rect(fill="#FAFAFA", color="none"), |
280 | 322 | plot_background=element_rect(fill="white", color="none"), |
281 | 323 | ) |
282 | 324 | ) |
283 | 325 |
|
284 | 326 | # Save |
285 | | -plot.save("plot.png", dpi=300, width=10, height=16) |
| 327 | +plot.save("plot.png", dpi=300, width=11, height=16) |
0 commit comments