Coverage for brodata / plot.py: 87%

218 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-20 14:37 +0000

1import logging 

2 

3import matplotlib.pyplot as plt 

4from matplotlib.patches import Rectangle 

5import numpy as np 

6import pandas as pd 

7 

8logger = logging.getLogger(__name__) 

9 

10 

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). 

16 

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. 

20 

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". 

34 

35 Returns 

36 ------- 

37 list of matplotlib.axes.Axes 

38 List of axes objects for each parameter plotted. 

39 

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() 

57 

58 axes = [] 

59 

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) 

68 

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) 

81 

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) 

97 

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 ) 

107 

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) 

115 

116 if ax is None: 

117 f.tight_layout(pad=0.0) 

118 

119 return axes 

120 

121 

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} 

171 

172sand_class_fine = [ 

173 "fijne categorie (O)", 

174 "zeer fijn (O)", 

175 "uiterst fijn (O)", 

176 "zeer fijn", 

177 "uiterst fijn", 

178] 

179 

180sand_class_medium = [ 

181 "matig fijn", 

182 "matig fijn (O)", 

183 "matig grof", 

184 "matig grof (O)", 

185 "midden categorie (O)", 

186] 

187 

188sand_class_course = [ 

189 "grove categorie (O)", 

190 "zeer grof", 

191 "zeer grof (O)", 

192 "uiterst grof", 

193 "uiterst grof (O)", 

194] 

195 

196 

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). 

205 

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`. 

219 

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. 

226 

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) 

271 

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) 

278 

279 if label is None: 

280 label = hoofdgrondsoort.capitalize() 

281 return color, label 

282 

283 

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. 

301 

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. 

331 

332 Returns 

333 ------- 

334 list 

335 List of matplotlib artist objects corresponding to the plotted lithology 

336 intervals. 

337 

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 

381 

382 

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. 

388 

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. 

392 

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`. 

415 

416 Returns 

417 ------- 

418 ax : matplotlib.axes.Axes 

419 The matplotlib axes object containing the lithology cross-section plot. 

420 

421 Raises 

422 ------ 

423 Exception 

424 If `kind` is not 'dino' or 'bro'. 

425 """ 

426 from shapely.geometry import LineString 

427 

428 ax = plt.gca() if ax is None else ax 

429 

430 line = LineString(line) if not isinstance(line, LineString) else line 

431 

432 if max_distance is not None: 

433 gdf = gdf[gdf.distance(line) < max_distance] 

434 

435 # calculate length along line 

436 s = pd.Series([line.project(point) for point in gdf.geometry], gdf.index) 

437 

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}")) 

459 

460 if legend: # add a legend 

461 add_lithology_legend(ax=ax) 

462 

463 return ax 

464 

465 

466def add_lithology_legend(ax, **kwargs): 

467 """ 

468 Add a custom legend to a matplotlib Axes for lithology categories. 

469 

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). 

474 

475 Returns 

476 ------- 

477 matplotlib.legend.Legend 

478 The legend object added to the axes. 

479 

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. 

489 

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) 

517 

518 

519def dino_lithology(df, **kwargs): 

520 """ 

521 Plot lithology information from a DataFrame containing lithology data from DINO. 

522 

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. 

529 

530 Returns 

531 ------- 

532 list 

533 List of matplotlib artist objects corresponding to the plotted lithology 

534 intervals. 

535 

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 ) 

553 

554 

555def bro_lithology(df, **kwargs): 

556 """ 

557 Plot lithology information from a DataFrame containing lithology data from BRO. 

558 

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. 

565 

566 Returns 

567 ------- 

568 list 

569 List of matplotlib artist objects corresponding to the plotted lithology 

570 intervals. 

571 

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 ) 

587 

588 

589def get_dino_lithology_colors(): 

590 """ 

591 Retrieve the standard color mapping for DINO lithology types. 

592 

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 

600 

601 

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 

610 

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 } 

629 

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 

754 

755 

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. 

773 

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. 

804 

805 Returns 

806 ------- 

807 list 

808 List of matplotlib Rectangle patch handles for the plotted soil layers. 

809 

810 Raises 

811 ------ 

812 ValueError 

813 If the specified soil_name_column is not present in the dataframe. 

814 

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 

823 

824 if lithology_properties is None: 

825 lithology_properties = get_bro_lithology_properties() 

826 

827 if soil_name_column not in df.columns: 

828 raise (ValueError(f"Column {soil_name_column} not present in df")) 

829 

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 

863 

864 

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. 

876 

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. 

895 

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 

907 

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)" 

913 

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