Coverage for brodata / plot.py: 87%
218 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-20 14:37 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-20 14:37 +0000
1import logging
3import matplotlib.pyplot as plt
4from matplotlib.patches import Rectangle
5import numpy as np
6import pandas as pd
8logger = logging.getLogger(__name__)
11def cone_penetration_test(
12 cpt, figsize=(10, 10), ax=None, linewidth=1.0, ylabel="Sondeertrajectlengte"
13):
14 """
15 Plot the results of a cone penetration test (CPT).
17 This function visualizes multiple CPT parameters (cone resistance, friction ratio,
18 local friction, and inclination resultant) against the test depth or trajectory
19 length. Each parameter is plotted on a separate x-axis, sharing the same y-axis.
21 Parameters
22 ----------
23 cpt : pandas.DataFrame or object
24 The CPT data as a DataFrame or an object with a 'conePenetrationTest' attribute
25 containing the DataFrame.
26 figsize : tuple, optional
27 Size of the figure to create if `ax` is not provided. Default is (10, 10).
28 ax : matplotlib.axes.Axes, optional
29 Existing matplotlib Axes to plot on. If None, a new figure and axes are created.
30 linewidth : float, optional
31 Width of the plot lines. Default is 1.0.
32 ylabel : str, optional
33 Label for the y-axis. Default is "Sondeertrajectlengte".
35 Returns
36 -------
37 list of matplotlib.axes.Axes
38 List of axes objects for each parameter plotted.
40 Notes
41 -----
42 - The y-axis is inverted to represent increasing depth downward.
43 - Each parameter is plotted only if its column in the DataFrame is not entirely NaN.
44 - The function supports plotting up to four parameters: 'coneResistance',
45 'frictionRatio', 'localFriction', and 'inclinationResultant'.
46 """
47 if hasattr(cpt, "conePenetrationTest"):
48 df = cpt.conePenetrationTest
49 else:
50 df = cpt
51 if ax is None:
52 f, ax1 = plt.subplots(figsize=figsize)
53 else:
54 ax1 = ax
55 ax1.set_ylabel(ylabel)
56 ax1.invert_yaxis()
58 axes = []
60 if not df["coneResistance"].isna().all():
61 ax1.plot(df["coneResistance"], df.index, color="b", linewidth=linewidth)
62 ax1.set_xlim(0, df["coneResistance"].max() * 2)
63 ax1.tick_params(axis="x", labelcolor="b")
64 lab = ax1.set_xlabel("Conusweerstand MPa", color="b")
65 lab.set_position((0.0, lab.get_position()[1]))
66 lab.set_horizontalalignment("left")
67 axes.append(ax1)
69 if not df["frictionRatio"].isna().all():
70 ax2 = ax1.twiny()
71 ax2.xaxis.set_ticks_position("bottom")
72 ax2.xaxis.set_label_position("bottom")
73 ax2.plot(df["frictionRatio"], df.index, color="g", linewidth=linewidth)
74 ax2.set_xlim(0, df["frictionRatio"].max() * 2)
75 ax2.tick_params(axis="x", labelcolor="g")
76 ax2.invert_xaxis()
77 lab = ax2.set_xlabel("Wrijvingsgetal", color="g")
78 lab.set_position((1.0, lab.get_position()[1]))
79 lab.set_horizontalalignment("right")
80 axes.append(ax2)
82 if not df["localFriction"].isna().all():
83 ax3 = ax1.twiny()
84 ax3.plot(
85 df["localFriction"],
86 df.index,
87 color="r",
88 linestyle="--",
89 linewidth=linewidth,
90 )
91 ax3.set_xlim(0, df["localFriction"].max() * 2)
92 ax3.tick_params(axis="x", labelcolor="r")
93 lab = ax3.set_xlabel("Plaatselijke wrijving", color="r")
94 lab.set_position((0.0, lab.get_position()[1]))
95 lab.set_horizontalalignment("left")
96 axes.append(ax3)
98 if not df["inclinationResultant"].isna().all():
99 ax4 = ax1.twiny()
100 ax4.plot(
101 df["inclinationResultant"],
102 df.index,
103 color="m",
104 linestyle="--",
105 linewidth=linewidth,
106 )
108 ax4.set_xlim(0, df["inclinationResultant"].max() * 2)
109 ax4.tick_params(axis="x", labelcolor="m")
110 ax4.invert_xaxis()
111 lab = ax4.set_xlabel("Hellingsresultante", color="m")
112 lab.set_position((1.0, lab.get_position()[1]))
113 lab.set_horizontalalignment("right")
114 axes.append(ax4)
116 if ax is None:
117 f.tight_layout(pad=0.0)
119 return axes
122lithology_colors = {
123 "ballast": (200 / 255, 200 / 255, 200 / 255), # checked at B38D4055
124 "baggert": (144 / 255, 144 / 255, 144 / 255), # checked at B60C5217
125 "baksteen": (200 / 255, 200 / 255, 200 / 255), # checked at B31H2923
126 "beton": (200 / 255, 200 / 255, 200 / 255), # same as baksteen, checked at B31H2925
127 "bruinkool": (140 / 255, 92 / 255, 54 / 255), # checked at B51G2426
128 "detritus": (157 / 255, 78 / 255, 64 / 255), # checked at B44A0733
129 "glauconietzand": (204 / 255, 1, 153 / 255), # checked at B49E1446
130 "grind": (216 / 255, 163 / 255, 32 / 255),
131 "hout": (157 / 255, 78 / 255, 64 / 255),
132 "ijzeroer": (242 / 255, 128 / 255, 13 / 255), # checked at B49E1446
133 "kalksteen": (140 / 255, 180 / 255, 1), # checked at B44B0062
134 "klei": (0, 146 / 255, 0),
135 "leem": (194 / 255, 207 / 255, 92 / 255),
136 "mergel ": (140 / 255, 180 / 255, 1), # checked at B60C0656
137 "mijnsteen": (200 / 255, 200 / 255, 200 / 255), # checked at B60C3651
138 "moeraskalk": (204 / 255, 102 / 255, 1), # checked at B31D1237
139 "oer": (200 / 255, 200 / 255, 200 / 255),
140 "puin": (200 / 255, 200 / 255, 200 / 255), # same as beton
141 "slurrie": (144 / 255, 144 / 255, 144 / 255), # same as slib, checked at B25A3512
142 "stenen": (216 / 255, 163 / 255, 32 / 255),
143 "stigmaria": (144 / 255, 144 / 255, 144 / 255), # same as baggert , checked at B60C5003
144 "veen": (157 / 255, 78 / 255, 64 / 255),
145 "zand": (1, 1, 0),
146 "zand fijn": (1, 1, 0), # same as zand
147 "zand midden": (243 / 255, 225 / 255, 6 / 255),
148 "zand grof": (231 / 255, 195 / 255, 22 / 255),
149 "sideriet": (242 / 255, 128 / 255, 13 / 255), # checked at B51D2864
150 "slib": (144 / 255, 144 / 255, 144 / 255),
151 "schalie": (156 / 255, 158 / 255, 99 / 255), # checked at B60C4442
152 "schelpen": (95 / 255, 95 / 255, 1),
153 "sterkGrindigZand": (
154 231 / 255,
155 195 / 255,
156 22 / 255,
157 ), # same as zand grove categorie
158 "steenkool": (58 / 255, 37 / 255, 22 / 255), # checked at B60C4442
159 "vast gesteente": (97 / 255, 97 / 255, 97 / 255), # checked at B60C5112
160 "wegverhardingsmateriaal": (
161 200 / 255,
162 200 / 255,
163 200 / 255,
164 ), # same as puin, checked at B25D3298
165 "zwakZandigeKlei": (0, 146 / 255, 0), # same as klei
166 "gyttja": (157 / 255, 78 / 255, 64 / 255), # same as hout, checked at B02G0307
167 "zandsteen": (200 / 255, 171 / 255, 55 / 255), # checked at B44B0119
168 "niet benoemd": (1, 1, 1),
169 "geen monster": (1, 1, 1),
170}
172sand_class_fine = [
173 "fijne categorie (O)",
174 "zeer fijn (O)",
175 "uiterst fijn (O)",
176 "zeer fijn",
177 "uiterst fijn",
178]
180sand_class_medium = [
181 "matig fijn",
182 "matig fijn (O)",
183 "matig grof",
184 "matig grof (O)",
185 "midden categorie (O)",
186]
188sand_class_course = [
189 "grove categorie (O)",
190 "zeer grof",
191 "zeer grof (O)",
192 "uiterst grof",
193 "uiterst grof (O)",
194]
197def get_lithology_color(
198 hoofdgrondsoort,
199 zandmediaanklasse=None,
200 drilling=None,
201 colors=None,
202):
203 """
204 Return the RGB color and label for a given lithology (hoofdgrondsoort).
206 Parameters
207 ----------
208 hoofdgrondsoort : str or any
209 The main soil type (lithology) to get the color for. If not a string (e.g.,
210 NaN), a default color is used.
211 zandmediaanklasse : str, optional
212 The sand median class, used for further classification if hoofdgrondsoort is
213 "zand".
214 drilling : any, optional
215 Optional drilling identifier, used for logging warnings.
216 colors : dict, optional
217 Dictionary mapping lithology names to RGB color tuples (0-1). If None, uses
218 the default `lithology_colors`.
220 Returns
221 -------
222 color : tuple of float
223 The RGB color as a tuple of floats in the range [0, 1].
224 label : str
225 The label for the lithology, possibly more specific for sand classes.
227 Notes
228 -----
229 - If the hoofdgrondsoort is not recognized, a warning is logged and a default white
230 color is returned.
231 - For "zand", the zandmediaanklasse determines the specific sand color and label.
232 - If colors is not provided, the function uses a default color mapping.
233 """
234 if colors is None:
235 colors = lithology_colors
236 label = None
237 if not isinstance(hoofdgrondsoort, str):
238 # hoofdgrondsoort is nan
239 color = colors["niet benoemd"]
240 label = str(hoofdgrondsoort)
241 elif hoofdgrondsoort in colors:
242 if hoofdgrondsoort == "zand":
243 if zandmediaanklasse in sand_class_fine:
244 color = colors["zand fijn"]
245 label = "Zand fijne categorie"
246 elif zandmediaanklasse in sand_class_medium:
247 label = "Zand midden categorie"
248 color = colors["zand midden"]
249 elif zandmediaanklasse in sand_class_course:
250 color = colors["zand grof"]
251 label = "Zand grove categorie"
252 else:
253 if not (
254 pd.isna(zandmediaanklasse)
255 or zandmediaanklasse in ["zandmediaan onduidelijk"]
256 ):
257 msg = f"Unknown zandmediaanklasse: {zandmediaanklasse}"
258 if drilling is not None:
259 msg = f"{msg} in drilling {drilling}"
260 logger.warning(msg)
261 # for zandmediaanklasse is None or something other than mentioned above
262 color = colors[hoofdgrondsoort]
263 else:
264 color = colors[hoofdgrondsoort]
265 else:
266 msg = f"No color defined for hoofdgrondsoort {hoofdgrondsoort}"
267 if drilling is not None:
268 msg = f"{msg} in drilling {drilling}"
269 logger.warning(msg)
270 color = (1.0, 1.0, 1.0)
272 if isinstance(color, (tuple, list, np.ndarray)) and np.any([x > 1 for x in color]):
273 logger.warning(
274 f"Color {color} specified as as integers between 0 and 255. "
275 "Please specify rgb-values as floats between 0 and 1."
276 )
277 color = tuple(x / 255 for x in color)
279 if label is None:
280 label = hoofdgrondsoort.capitalize()
281 return color, label
284def lithology(
285 df,
286 top,
287 bot,
288 kind,
289 sand_class=None,
290 ax=None,
291 x=0.5,
292 z=0.0,
293 solid_capstyle="butt",
294 linewidth=6,
295 drilling=None,
296 colors=None,
297 **kwargs,
298):
299 """
300 Plot lithology intervals from a DataFrame as vertical lines or filled spans.
302 Parameters
303 ----------
304 df : pandas.DataFrame
305 DataFrame containing lithology data.
306 top : str
307 Column name in `df` representing the top depth of each interval.
308 bot : str
309 Column name in `df` representing the bottom depth of each interval.
310 kind : str
311 Column name in `df` specifying the lithology type for color mapping.
312 sand_class : str, optional
313 Column name in `df` specifying sand class for color mapping (default: None).
314 ax : matplotlib.axes.Axes, optional
315 Matplotlib axis to plot on. If None, uses current axis (default: None).
316 x : float, optional
317 X-coordinate for vertical lines (default: 0.5). If None or not finite, uses
318 filled spans.
319 z : float, optional
320 Reference depth for vertical positioning (default: 0.0).
321 solid_capstyle : str, optional
322 Cap style for vertical lines (default: "butt").
323 linewidth : float, optional
324 Line width for plotting (default: 6).
325 drilling : any, optional
326 Additional drilling information for color mapping (default: None).
327 colors : dict, optional
328 Custom color mapping for lithologies (default: None).
329 **kwargs
330 Additional keyword arguments passed to matplotlib plotting functions.
332 Returns
333 -------
334 list
335 List of matplotlib artist objects corresponding to the plotted lithology
336 intervals.
338 Notes
339 -----
340 - If `x` is provided and finite, plots vertical lines at `x`.
341 - If `x` is None or not finite, plots filled horizontal spans between `z_top` and
342 `z_bot`.
343 - Uses `get_lithology_color` to determine color and label for each interval.
344 """
345 h = []
346 if not isinstance(df, pd.DataFrame):
347 return h
348 if ax is None:
349 ax = plt.gca()
350 for index in df.index:
351 z_top = z - df.at[index, top]
352 z_bot = z - df.at[index, bot]
353 zandmediaanklasse = None if sand_class is None else df.at[index, sand_class]
354 color, label = get_lithology_color(
355 df.at[index, kind], zandmediaanklasse, drilling=drilling, colors=colors
356 )
357 if x is not None and np.isfinite(x):
358 h.append(
359 ax.plot(
360 [x, x],
361 [z_bot, z_top],
362 color=color,
363 label=label,
364 linewidth=linewidth,
365 solid_capstyle=solid_capstyle,
366 **kwargs,
367 )
368 )
369 else:
370 h.append(
371 ax.axhspan(
372 z_bot,
373 z_top,
374 facecolor=color,
375 label=label,
376 linewidth=linewidth,
377 **kwargs,
378 )
379 )
380 return h
383def lithology_along_line(
384 gdf, line, kind, ax=None, legend=True, max_distance=None, **kwargs
385):
386 """
387 Plot lithological drillings along a cross-sectional line.
389 This function visualizes subsurface lithology data from borehole records
390 in a 2D cross-section view, based on their proximity to a specified line.
391 It supports both 'dino' and 'bro' formatted datasets.
393 Parameters
394 ----------
395 gdf : geopandas.GeoDataFrame
396 GeoDataFrame containing borehole data. This typically includes geometry and
397 lithology-related columns. Can be retrieved using, for example,
398 `brodata.dino.get_boormonsterprofiel`.
399 line : shapely.geometry.LineString or list of tuple[float, float]
400 The cross-sectional line along which to plot the lithologies. Determines the
401 x-coordinates of the lithology logs. If `max_distance` is set, only boreholes
402 within this distance from the line will be included.
403 kind : str
404 Specifies the data source format. Must be either 'dino' or 'bro'.
405 ax : matplotlib.axes.Axes, optional
406 The matplotlib axes object to plot on. If None, uses the current axes.
407 legend : bool, optional
408 Whether to include a legend for the lithology classes. Default is True.
409 max_distance : float, optional
410 Maximum distance (in the same units as the GeoDataFrame's CRS) from the line
411 within which boreholes are included in the cross-section. If None, includes all.
412 **kwargs :
413 Additional keyword arguments passed to either `dino_lithology` or
414 `bro_lithology`.
416 Returns
417 -------
418 ax : matplotlib.axes.Axes
419 The matplotlib axes object containing the lithology cross-section plot.
421 Raises
422 ------
423 Exception
424 If `kind` is not 'dino' or 'bro'.
425 """
426 from shapely.geometry import LineString
428 ax = plt.gca() if ax is None else ax
430 line = LineString(line) if not isinstance(line, LineString) else line
432 if max_distance is not None:
433 gdf = gdf[gdf.distance(line) < max_distance]
435 # calculate length along line
436 s = pd.Series([line.project(point) for point in gdf.geometry], gdf.index)
438 for index in gdf.index:
439 if kind == "dino":
440 dino_lithology(
441 gdf.at[index, "lithologie_lagen"],
442 z=gdf.at[index, "Maaiveldhoogte (m tov NAP)"],
443 x=s[index],
444 drilling=index,
445 ax=ax,
446 **kwargs,
447 )
448 elif kind == "bro":
449 if len(gdf.at[index, "descriptiveBoreholeLog"]) > 0:
450 msg = (
451 f"More than 1 descriptiveBoreholeLog for {index}. "
452 "Only plotting the first one."
453 )
454 logger.warning(msg)
455 df = gdf.at[index, "descriptiveBoreholeLog"][0]["layer"]
456 bro_lithology(df, x=s[index], drilling=index, ax=ax, **kwargs)
457 else:
458 raise (Exception(f"Unknown kind: {kind}"))
460 if legend: # add a legend
461 add_lithology_legend(ax=ax)
463 return ax
466def add_lithology_legend(ax, **kwargs):
467 """
468 Add a custom legend to a matplotlib Axes for lithology categories.
470 ax : matplotlib.axes.Axes
471 The matplotlib Axes object to which the legend will be added.
472 **kwargs : dict, optional
473 Additional keyword arguments passed to `ax.legend()` (e.g., loc, fontsize).
475 Returns
476 -------
477 matplotlib.legend.Legend
478 The legend object added to the axes.
480 Notes
481 -----
482 The function reorders legend entries so that common lithology categories appear in a
483 preferred order:
484 - "Veen", "Klei", "Leem", "Zand fijne categorie", "Zand midden categorie",
485 "Zand grove categorie", "Zand", "Grind"
486 These are placed at the top of the legend, while "Niet benoemd" and "Geen monster"
487 are placed at the bottom.
488 Duplicate labels are removed, keeping only the first occurrence.
490 """
491 handles, labels = ax.get_legend_handles_labels()
492 labels, index = np.unique(np.array(labels), return_index=True)
493 boven = np.array(
494 [
495 "Veen",
496 "Klei",
497 "Leem",
498 "Zand fijne categorie",
499 "Zand midden categorie",
500 "Zand grove categorie",
501 "Zand",
502 "Grind",
503 ]
504 )
505 for lab in boven:
506 if lab in labels:
507 mask = labels == lab
508 labels = np.hstack((labels[mask], labels[~mask]))
509 index = np.hstack((index[mask], index[~mask]))
510 onder = np.array(["Niet benoemd", "Geen monster"])
511 for lab in onder:
512 if lab in labels:
513 mask = labels == lab
514 labels = np.hstack((labels[~mask], labels[mask]))
515 index = np.hstack((index[~mask], index[mask]))
516 return ax.legend(np.array(handles)[index], labels, **kwargs)
519def dino_lithology(df, **kwargs):
520 """
521 Plot lithology information from a DataFrame containing lithology data from DINO.
523 Parameters
524 ----------
525 df : pandas.DataFrame
526 The input DataFrame containing lithology data.
527 **kwargs
528 Additional keyword arguments passed to the underlying `lithology` function.
530 Returns
531 -------
532 list
533 List of matplotlib artist objects corresponding to the plotted lithology
534 intervals.
536 Notes
537 -----
538 This function is a wrapper around the `lithology` function, mapping the DataFrame
539 columns:
540 - 'Bovenkant laag (m beneden maaiveld)' as top
541 - 'Onderkant laag (m beneden maaiveld)' as bot
542 - 'Hoofdgrondsoort' as kind
543 - 'Zandmediaanklasse' as sand_class
544 """
545 return lithology(
546 df,
547 top="Bovenkant laag (m beneden maaiveld)",
548 bot="Onderkant laag (m beneden maaiveld)",
549 kind="Hoofdgrondsoort",
550 sand_class="Zandmediaanklasse",
551 **kwargs,
552 )
555def bro_lithology(df, **kwargs):
556 """
557 Plot lithology information from a DataFrame containing lithology data from BRO.
559 Parameters
560 ----------
561 df : pandas.DataFrame
562 The input DataFrame containing lithology data.
563 **kwargs
564 Additional keyword arguments passed to the underlying `lithology` function.
566 Returns
567 -------
568 list
569 List of matplotlib artist objects corresponding to the plotted lithology
570 intervals.
572 Notes
573 -----
574 This function is a wrapper around the `lithology` function, mapping the DataFrame
575 columns:
576 - 'upperBoundary' as the top,
577 - 'lowerBoundary' as the bot,
578 - 'geotechnicalSoilName' as kind.
579 """
580 return lithology(
581 df,
582 top="upperBoundary",
583 bot="lowerBoundary",
584 kind="geotechnicalSoilName",
585 **kwargs,
586 )
589def get_dino_lithology_colors():
590 """
591 Retrieve the standard color mapping for DINO lithology types.
593 Returns
594 -------
595 dict
596 A dictionary mapping lithology type names to their corresponding color values
597 used for visualization in plots and maps.
598 """
599 return lithology_colors
602def get_bro_lithology_properties():
603 """
604 Retrieve a comprehensive legend dictionary for BRO (Basisregistratie Ondergrond) lithology properties.
605 This function defines visual properties (colors and hatching patterns) for various soil and rock types
606 used in geological mapping, including:
607 - Primary lithologies (veen, klei, leem, zand, grind, silt)
608 - Unspecified categories (nietBepaald, grondNietGespecificeerd)
609 - Complex composite lithologies with width-based proportions for mixed soil types
611 Returns
612 -------
613 dict: A dictionary mapping lithology type names (str) to their visual properties (dict or list of dict).
614 Each property dict contains:
615 - "color": tuple of RGB values (normalized to 0-1 range)
616 - "hatch": str, optional pattern for visualization ("-", "/", "\\", ".", "o", "|")
617 - "width": float, optional proportion value used for composite lithologies
618 """
619 legend = {
620 "veen": {"color": (153 / 255, 76 / 255, 58 / 255), "hatch": "-"},
621 "klei": {"color": (0, 150 / 255, 8 / 255), "hatch": "/"},
622 "leem": {"color": (219 / 255, 219 / 255, 219 / 255), "hatch": "\\"},
623 "zand": {"color": (254 / 255, 254 / 255, 8 / 255), "hatch": "."},
624 "grind": {"color": (243 / 255, 192 / 255, 39 / 255), "hatch": "o"},
625 "silt": {"color": (219 / 255, 219 / 255, 219 / 255), "hatch": "|"},
626 "nietBepaald": {"color": (112 / 255, 48 / 255, 160 / 255)},
627 "grondNietGespecificeerd": {"color": (1, 1, 1)},
628 }
630 legend = legend | {
631 "mineraalarmVeen": legend["veen"],
632 "zwakZandigVeen": [
633 {"width": 50 / 60} | legend["veen"],
634 {"width": 10 / 60} | legend["zand"],
635 ],
636 "sterkZandigVeen": [
637 {"width": 41 / 60} | legend["veen"],
638 {"width": 19 / 60} | legend["zand"],
639 ],
640 "zwakKleiigVeen": [ # not checked at broloket
641 {"width": 50 / 60} | legend["veen"],
642 {"width": 10 / 60} | legend["klei"],
643 ],
644 "sterkKleiigVeen": [
645 {"width": 41 / 60} | legend["veen"],
646 {"width": 19 / 60} | legend["klei"],
647 ],
648 "kleiigVeen": [
649 {"width": 42 / 60} | legend["veen"],
650 {"width": 18 / 60} | legend["klei"],
651 ],
652 "zwakZandigSilt": [
653 {"width": 48 / 60} | legend["silt"],
654 {"width": 12 / 60} | legend["zand"],
655 ],
656 "zwakGrindigeKlei": [
657 {"width": 48 / 60} | legend["klei"],
658 {"width": 12 / 60} | legend["grind"],
659 ],
660 "zwakZandigeKlei": [
661 {"width": 48 / 60} | legend["klei"],
662 {"width": 12 / 60} | legend["zand"],
663 ],
664 "zwakZandigeKleiMetGrind": [
665 {"width": 48 / 60} | legend["klei"],
666 {"width": 12 / 60} | legend["zand"],
667 ],
668 "matigZandigeKlei": [
669 {"width": 41 / 60} | legend["klei"],
670 {"width": 19 / 60} | legend["zand"],
671 ],
672 "sterkZandigeKlei": [
673 {"width": 30 / 60} | legend["klei"],
674 {"width": 30 / 60} | legend["zand"],
675 ],
676 "sterkZandigeKleiMetGrind": [
677 {"width": 36 / 60} | legend["klei"],
678 {"width": 24 / 60} | legend["leem"], # with a hatch
679 ],
680 "zwakSiltigeKlei": [
681 {"width": 50 / 60} | legend["klei"],
682 {"width": 10 / 60} | legend["leem"], # with a hatch
683 ],
684 "matigSiltigeKlei": [
685 {"width": 41 / 60} | legend["klei"],
686 {"width": 19 / 60} | legend["leem"], # with a hatch
687 ],
688 "sterkSiltigeKlei": [
689 {"width": 30 / 60} | legend["klei"],
690 {"width": 30 / 60} | legend["leem"], # with a hatch
691 ],
692 "uiterstSiltigeKlei": [
693 {"width": 26 / 60} | legend["klei"],
694 {"width": 34 / 60} | {"color": legend["silt"]["color"]}, # without a hatch
695 ],
696 "zwakZandigeLeem": [
697 {"width": 50 / 60} | legend["leem"],
698 {"width": 10 / 60} | legend["zand"],
699 ],
700 "sterkZandigeLeem": [
701 {"width": 30 / 60} | legend["leem"],
702 {"width": 30 / 60} | legend["zand"],
703 ],
704 "zwakGrindigZand": [
705 {"width": 48 / 60} | legend["zand"],
706 {"width": 12 / 60} | legend["grind"],
707 ],
708 "sterkGrindigZand": [
709 {"width": 36 / 60} | legend["zand"],
710 {"width": 24 / 60} | legend["grind"],
711 ],
712 "zwakSiltigZand": [
713 {"width": 50 / 60} | legend["zand"],
714 {"width": 10 / 60} | legend["leem"],
715 ],
716 "matigSiltigZand": [
717 {"width": 41 / 60} | legend["zand"],
718 {"width": 19 / 60} | legend["leem"],
719 ],
720 "sterkSiltigZand": [
721 {"width": 30 / 60} | legend["zand"],
722 {"width": 30 / 60} | legend["leem"],
723 ],
724 "siltigZandMetGrind": [
725 {"width": 42 / 60} | legend["zand"],
726 {"width": 18 / 60} | legend["silt"],
727 ],
728 "kleiigZand": [
729 {"width": 50 / 60} | legend["zand"],
730 {"width": 10 / 60} | legend["klei"],
731 ],
732 "kleiigZandMetGrind": [
733 {"width": 42 / 60} | legend["zand"],
734 {"width": 18 / 60} | legend["klei"],
735 ],
736 "siltigZand": [
737 {"width": 42 / 60} | legend["zand"],
738 {"width": 18 / 60} | legend["silt"],
739 ],
740 "zwakZandigGrind": [
741 {"width": 48 / 60} | legend["grind"],
742 {"width": 12 / 60} | legend["zand"],
743 ],
744 "sterkZandigGrind": [
745 {"width": 36 / 60} | legend["grind"],
746 {"width": 24 / 60} | legend["zand"],
747 ],
748 "zandNietGespecificeerd": [
749 {"width": (24 / 60)} | legend["zand"],
750 {"width": (36 / 60)} | legend["grondNietGespecificeerd"],
751 ],
752 }
753 return legend
756def bro_lithology_advanced(
757 df,
758 soil_name_column="geotechnicalSoilName",
759 z=0.0,
760 x=0.5,
761 width=0.1,
762 lithology_properties=None,
763 ax=None,
764 hatch_factor=2,
765 hatch_color=(0.0, 0.0, 0.0, 0.2),
766 hatch_linewidth=2,
767 bro_id=None,
768):
769 """
770 Plot advanced lithology data from a BRO (Basisregistratie Ondergrond) dataframe.
771 This function visualizes soil layers with customizable colors, patterns, and
772 hatching, creating a detailed stratigraphic column representation.
774 Parameters
775 ----------
776 df : pandas.DataFrame
777 DataFrame containing borehole lithology data with columns for soil names and
778 depth boundaries.
779 soil_name_column : str, optional
780 Name of the column containing soil classifications. Default is
781 "geotechnicalSoilName". Other options include "soilNameNEN5104",
782 "standardSoilName" or "soilName" depending on data source.
783 z : float, optional
784 Base depth (z-coordinate) for the plot in meters. Default is 0.0.
785 x : float, optional
786 Horizontal x-coordinate position for the lithology column. Default is 0.5.
787 width : float, optional
788 Base width of the lithology column. Default is 0.1.
789 lithology_properties : dict, optional
790 Dictionary mapping soil names to their visual properties (color, hatch pattern,
791 width). If None, properties are loaded from get_bro_lithology_properties().
792 Default is None.
793 ax : matplotlib.axes.Axes, optional
794 Matplotlib axes object to plot on. If None, uses current axes. Default is None.
795 hatch_factor : float, optional
796 Scaling factor for hatch pattern density. Default is 2.
797 hatch_color : tuple, optional
798 RGBA color tuple for hatch patterns. Default is (0.0, 0.0, 0.0, 0.2) (semi-
799 transparent black).
800 hatch_linewidth : float, optional
801 Line width for hatch patterns in points. Default is 2.
802 bro_id : str or int, optional
803 BRO identifier for logging purposes. Used in warning messages. Default is None.
805 Returns
806 -------
807 list
808 List of matplotlib Rectangle patch handles for the plotted soil layers.
810 Raises
811 ------
812 ValueError
813 If the specified soil_name_column is not present in the dataframe.
815 Notes
816 -----
817 - Soil names with NaN values are mapped to "grondNietGespecificeerd" (unspecified ground).
818 - Unsupported soil names generate warning messages but do not halt execution.
819 - Lithology properties can be overridden per layer using the lithology_properties dictionary.
820 """
821 # TODO: create a legend. See https://stackoverflow.com/questions/55501860/how-to-put-multiple-colormap-patches-in-a-matplotlib-legend
822 ax = plt.gca() if ax is None else ax
824 if lithology_properties is None:
825 lithology_properties = get_bro_lithology_properties()
827 if soil_name_column not in df.columns:
828 raise (ValueError(f"Column {soil_name_column} not present in df"))
830 handles = []
831 for index in df.index:
832 # soil_name_column = "geotechnicalSoilName" for GeotechnicalBoreholeResearch
833 # soil_name_column = "soilNameNEN5104" for GeologicalBoreholeResearch
834 # soil_name_column = "standardSoilName" for PedologicalBoreholeResearch
835 # soil_name_column = "soilName" for DescriptiveBoreholeLog in SiteAssessmentData
836 sn = df.at[index, soil_name_column]
837 # sn can be nan
838 if pd.isna(sn):
839 sn = "grondNietGespecificeerd"
840 left = x - width / 2
841 if sn not in lithology_properties:
842 msg = f"SoilName {sn} not supported"
843 if bro_id is not None:
844 msg = f"{msg} (found at broId {bro_id})"
845 logger.warning(f"{msg}. Please add {sn} to lithology_properties.")
846 continue
847 ps = lithology_properties[sn]
848 if isinstance(ps, dict):
849 ps = [ps]
850 for p in ps:
851 xy = (left, z - df.at[index, "upperBoundary"])
852 w = p["width"] * width if "width" in p else width
853 h = df.at[index, "upperBoundary"] - df.at[index, "lowerBoundary"]
854 hatch = p["hatch"] * hatch_factor if "hatch" in p else None
855 h = ax.add_patch(
856 Rectangle(xy, w, h, facecolor=p["color"], hatch=hatch, edgecolor="k")
857 )
858 h._hatch_color = hatch_color
859 h._hatch_linewidth = hatch_linewidth
860 left = left + w
861 handles.append(h)
862 return handles
865def descriptive_borehole_log(
866 bhr,
867 attr="descriptiveBoreholeLog",
868 soil_name_column="geotechnicalSoilName",
869 figsize=None,
870 width=0.2,
871 ylabel=None,
872 nap=True,
873):
874 """
875 Generate a descriptive borehole log visualization.
877 Parameters
878 ----------
879 bhr : object
880 Borehole object containing descriptive log data, offset, and bored interval information.
881 soil_name_column : str, optional
882 Name of the column containing soil classifications. Default is
883 "geotechnicalSoilName". Other options include "soilNameNEN5104",
884 "standardSoilName" or "soilName" depending on data source.
885 figsize : tuple, optional
886 Figure size as (width, height) in inches. If None, uses matplotlib default.
887 width : float, default=0.2
888 Width of each borehole column in the plot.
889 ylabel : str, optional
890 Label for the y-axis. If None, defaults to "z (m t.o.v. NAP)" if nap=True,
891 or "z (m t.o.v. maaiveld)" if nap=False.
892 nap : bool, default=True
893 If True, use NAP (Normaal Amsterdams Peil) reference level for depth.
894 If False, use ground level (maaiveld) as reference.
896 Returns
897 -------
898 f : matplotlib.figure.Figure
899 The generated figure object.
900 ax : matplotlib.axes.Axes
901 The axes object containing the plot.
902 """
903 if nap:
904 z = bhr.offset
905 else:
906 z = 0.0
908 if ylabel is None:
909 if nap:
910 ylabel = "z (m t.o.v. NAP)"
911 else:
912 ylabel = "z (m t.o.v. maaiveld)"
914 f, ax = plt.subplots(figsize=figsize, layout="constrained")
915 xticks = []
916 xticklabels = []
917 df = getattr(bhr, attr)
918 if not isinstance(df, list):
919 df = [df]
920 for x, bl in enumerate(df):
921 df = bl["layer"]
922 bro_lithology_advanced(
923 df, x=x, width=width, z=z, ax=ax, soil_name_column=soil_name_column
924 )
925 xticks.append(x)
926 xticklabels.append(bl["descriptionQuality"])
927 ax.set_xlim(-0.5, x + 0.5)
928 ax.set_ylim(z - bhr.boredInterval["endDepth"].max(), z)
929 ax.set_xticks(xticks)
930 ax.set_xticklabels(xticklabels)
931 ax.set_ylabel(ylabel)
932 ax.set_axisbelow(True)
933 ax.grid()
934 return f, ax