py-pilecore 0.3.4__py3-none-any.whl → 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of py-pilecore might be problematic. Click here for more details.

@@ -0,0 +1,679 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from functools import lru_cache
5
+ from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple
6
+
7
+ import matplotlib.patches as patches
8
+ import numpy as np
9
+ import pandas as pd
10
+ from matplotlib import pyplot as plt
11
+ from matplotlib.axes import Axes
12
+ from matplotlib.collections import PatchCollection
13
+ from matplotlib.figure import Figure
14
+ from numpy.typing import NDArray
15
+ from scipy.spatial import Delaunay, Voronoi, voronoi_plot_2d
16
+
17
+ from pypilecore.results.soil_properties import SoilProperties, get_soil_layer_handles
18
+
19
+
20
+ class MaxBearingTable:
21
+ """
22
+ Object that contains the results belonging to the maximum net design bearing capacity (R_c_d_net) for a single CPT.
23
+
24
+ *Not meant to be instantiated by the user.*
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ pile_tip_level_nap: Sequence[float],
30
+ R_c_d_net: Sequence[float],
31
+ F_nk_d: Sequence[float],
32
+ origin: Sequence[str],
33
+ ):
34
+ """
35
+ Object that contains the results belonging to the maximum net design bearing capacity (R_c_d_net) for a single CPT.
36
+
37
+ Parameters
38
+ ----------
39
+ pile_tip_level_nap
40
+ The elevation of the pile-tip, in [m] w.r.t. NAP.
41
+ R_c_d_net
42
+ The maximum net design bearing capacity, in [kN].
43
+ F_nk_d
44
+ The net design bearing capacity, in [kN].
45
+ origin
46
+ The origin of the CPT data.
47
+ """
48
+ self._pile_tip_level_nap = pile_tip_level_nap
49
+ self._R_c_d_net = R_c_d_net
50
+ self._F_nk_d = F_nk_d
51
+ self._origin = origin
52
+
53
+ @property
54
+ def pile_tip_level_nap(self) -> NDArray[np.float64]:
55
+ """The elevation of the pile-tip, in [m] w.r.t. NAP."""
56
+ return np.array(self._pile_tip_level_nap).astype(np.float64)
57
+
58
+ @property
59
+ def R_c_d_net(self) -> NDArray[np.float64]:
60
+ """The maximum net design bearing capacity, in [kN]."""
61
+ return np.array(self._R_c_d_net).astype(np.float64)
62
+
63
+ @property
64
+ def F_nk_d(self) -> NDArray[np.float64]:
65
+ """The net design bearing capacity, in [kN]."""
66
+ return np.array(self._F_nk_d).astype(np.float64)
67
+
68
+ @property
69
+ def origin(self) -> NDArray[np.str_]:
70
+ """The origin of the CPT data."""
71
+ return np.array(self._origin).astype(np.str_)
72
+
73
+ @lru_cache
74
+ def to_pandas(self) -> pd.DataFrame:
75
+ """Get the pandas.DataFrame representation"""
76
+ return pd.DataFrame(
77
+ dict(
78
+ pile_tip_level_nap=self.pile_tip_level_nap,
79
+ R_c_d_net=self.R_c_d_net,
80
+ F_nk_d=self.F_nk_d,
81
+ origin=self.origin,
82
+ )
83
+ )
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class MaxBearingResult:
88
+ """
89
+ Object that contains the results of a PileCore single-cpt calculation.
90
+
91
+ *Not meant to be instantiated by the user.*
92
+
93
+ Attributes
94
+ ----------
95
+ soil_properties
96
+ The object with soil properties
97
+ pile_head_level_nap
98
+ The elevation of the pile-head, in [m] w.r.t. NAP.
99
+ table
100
+ The object with CPT results.
101
+ """
102
+
103
+ soil_properties: SoilProperties
104
+ pile_head_level_nap: float
105
+ table: MaxBearingTable
106
+
107
+ def to_pandas(self) -> pd.DataFrame:
108
+ """Get the pandas.DataFrame representation"""
109
+ return self.table.to_pandas()
110
+
111
+ def plot_bearing_capacities(
112
+ self,
113
+ axes: Optional[Axes] = None,
114
+ figsize: Tuple[float, float] = (8, 10),
115
+ add_legend: bool = True,
116
+ **kwargs: Any,
117
+ ) -> Axes:
118
+ """
119
+ Plot the bearing calculation results on an `Axes' object.
120
+
121
+ Parameters
122
+ ----------
123
+ axes:
124
+ Optional `Axes` object where the bearing capacities can be plotted on.
125
+ If not provided, a new `plt.Figure` will be activated and the `Axes`
126
+ object will be created and returned.
127
+ figsize:
128
+ Size of the activate figure, as the `plt.figure()` argument.
129
+ add_legend:
130
+ Add a legend to the second axes object
131
+ **kwargs:
132
+ All additional keyword arguments are passed to the `pyplot.subplots()` call.
133
+
134
+ Returns
135
+ -------
136
+ axes:
137
+ The `Axes` object where the bearing capacities were plotted on.
138
+ """
139
+
140
+ # Create axes objects if not provided
141
+ if axes is not None:
142
+ if not isinstance(axes, Axes):
143
+ raise ValueError(
144
+ "'axes' argument to plot_bearing_capacities() must be a `pyplot.axes.Axes` object or None."
145
+ )
146
+ else:
147
+ kwargs_subplot = {
148
+ "figsize": figsize,
149
+ "tight_layout": True,
150
+ }
151
+
152
+ kwargs_subplot.update(kwargs)
153
+
154
+ _, axes = plt.subplots(1, 1, **kwargs_subplot)
155
+
156
+ if not isinstance(axes, Axes):
157
+ raise ValueError(
158
+ "Could not create Axes objects. This is probably due to invalid matplotlib keyword arguments. "
159
+ )
160
+
161
+ # add horizontal lines
162
+ axes.axhline(
163
+ y=self.soil_properties.groundwater_level_ref,
164
+ color="tab:blue",
165
+ linestyle="--",
166
+ label="Groundwater level",
167
+ )
168
+ axes.axhline(
169
+ y=self.soil_properties.surface_level_ref,
170
+ color="tab:brown",
171
+ linestyle="--",
172
+ label="Surface level",
173
+ )
174
+
175
+ # add bearing result subplot
176
+ axes.plot(
177
+ np.array(self.table.F_nk_d),
178
+ self.table.pile_tip_level_nap,
179
+ color="tab:orange",
180
+ label="Fnk;d",
181
+ )
182
+ axes.plot(
183
+ np.array(self.table.R_c_d_net),
184
+ self.table.pile_tip_level_nap,
185
+ label=r"Rc;net;d",
186
+ lw=3,
187
+ color="tab:blue",
188
+ )
189
+ axes.set_xlabel("Force [kN]")
190
+
191
+ # add legend
192
+ if add_legend:
193
+ axes.legend(
194
+ loc="upper left",
195
+ bbox_to_anchor=(1, 1),
196
+ )
197
+
198
+ # set grid
199
+ axes.grid()
200
+
201
+ return axes
202
+
203
+ def plot_bearing_overview(
204
+ self,
205
+ figsize: Tuple[float, float] = (10.0, 12.0),
206
+ width_ratios: Tuple[float, float, float] = (1, 0.1, 2),
207
+ add_legend: bool = True,
208
+ **kwargs: Any,
209
+ ) -> Figure:
210
+ """
211
+ Plot an overview of the bearing-capacities, including the .
212
+
213
+ Parameters
214
+ ----------
215
+ figsize:
216
+ Size of the activate figure, as the `plt.figure()` argument.
217
+ width_ratios:
218
+ Tuple of width-ratios of the subplots, as the `plt.GridSpec` argument.
219
+ add_legend:
220
+ Add a legend to the second axes object
221
+ **kwargs:
222
+ All additional keyword arguments are passed to the `pyplot.subplots()` call.
223
+
224
+ Returns
225
+ -------
226
+ fig:
227
+ The matplotlib Figure
228
+ """
229
+
230
+ kwargs_subplot = {
231
+ "gridspec_kw": {"width_ratios": width_ratios},
232
+ "sharey": "row",
233
+ "figsize": figsize,
234
+ "tight_layout": True,
235
+ }
236
+
237
+ kwargs_subplot.update(kwargs)
238
+
239
+ fig, _ = plt.subplots(
240
+ 1,
241
+ 3,
242
+ **kwargs_subplot,
243
+ )
244
+
245
+ ax_qc, ax_layers, ax_bearing = fig.axes
246
+ ax_rf = ax_qc.twiny()
247
+ assert isinstance(ax_rf, Axes)
248
+
249
+ # Plot bearing capacities
250
+ self.soil_properties.cpt_table.plot_qc(ax_qc, add_legend=False)
251
+ self.soil_properties.cpt_table.plot_friction_ratio(ax_rf, add_legend=False)
252
+ self.soil_properties.plot_layers(ax_layers, add_legend=False)
253
+ self.plot_bearing_capacities(axes=ax_bearing, add_legend=False)
254
+
255
+ if add_legend:
256
+ ax_qc_legend_handles_list = ax_qc.get_legend_handles_labels()[0]
257
+ ax_rf_legend_handles_list = ax_rf.get_legend_handles_labels()[0]
258
+ ax_layers_legend_handles_list = get_soil_layer_handles()
259
+
260
+ # Omit last 2 duplicate "bearing" handles
261
+ # (groundwater_level and surface_level):
262
+ ax_bearing_legend_handles_list = ax_bearing.get_legend_handles_labels()[0][
263
+ 2:
264
+ ]
265
+
266
+ handles_list = [
267
+ *ax_qc_legend_handles_list,
268
+ *ax_rf_legend_handles_list,
269
+ *ax_layers_legend_handles_list,
270
+ *ax_bearing_legend_handles_list,
271
+ ]
272
+
273
+ ax_bearing.legend(
274
+ handles=handles_list,
275
+ loc="upper left",
276
+ bbox_to_anchor=(1, 1),
277
+ title="name: " + self.soil_properties.test_id
278
+ if self.soil_properties.test_id is not None
279
+ else "name: unknown",
280
+ )
281
+
282
+ return fig
283
+
284
+
285
+ class MaxBearingResults:
286
+ """Object containing the results for the maximum net design bearing capacity (R_c_d_net) for every CPT."""
287
+
288
+ def __init__(self, cpt_results_dict: Dict[str, MaxBearingResult]):
289
+ """
290
+ Object containing the results for the maximum net design bearing capacity (R_c_d_net) for every CPT.
291
+
292
+ Parameters
293
+ ----------
294
+ cpt_results_dict
295
+ The results for the maximum net design bearing capacity (R_c_d_net) for every CPT.
296
+ """
297
+ self._cpt_results_dict = cpt_results_dict
298
+
299
+ def __getitem__(self, test_id: str) -> MaxBearingResult:
300
+ if not isinstance(test_id, str):
301
+ raise TypeError(f"Expected a test-id as a string, but got: {type(test_id)}")
302
+
303
+ return self.get_cpt_results(test_id)
304
+
305
+ @property
306
+ def cpt_results_dict(self) -> Dict[str, MaxBearingResult]:
307
+ """The dictionary with the MaxBearingResult for each CPT."""
308
+ return self._cpt_results_dict
309
+
310
+ @property
311
+ def test_ids(self) -> List[str]:
312
+ """The test-ids of the CPTs."""
313
+ return list(self.cpt_results_dict.keys())
314
+
315
+ @property
316
+ def results(self) -> List[MaxBearingResult]:
317
+ """The computed results, as a list of MaxBearingResult objects."""
318
+ return list(self.cpt_results_dict.values())
319
+
320
+ def get_cpt_results(self, test_id: str) -> MaxBearingResult:
321
+ """
322
+ Returns the `MaxBearingResult` object for the provided test_id.
323
+ """
324
+
325
+ if test_id not in self.cpt_results_dict.keys():
326
+ raise ValueError(
327
+ f"No Cpt-results were calculated for this test-id: {test_id}. "
328
+ "Please check the spelling or run a new calculation for this CPT."
329
+ )
330
+
331
+ return self.cpt_results_dict[test_id]
332
+
333
+ def get_results_per_cpt(self, column_name: str) -> pd.DataFrame:
334
+ """
335
+ Returns a pandas dataframe with a single result-item, organized per CPT
336
+ (test-id) and pile-tip-level-nap.
337
+
338
+ Parameters
339
+ ----------
340
+ column_name:
341
+ The name of the result-item / column name of the single-cpt-results table.
342
+ """
343
+ if column_name not in self.to_pandas().columns or column_name in [
344
+ "pile_tip_level_nap",
345
+ "test_id",
346
+ ]:
347
+ raise ValueError("Invalid column_name provided.")
348
+
349
+ results = pd.pivot(
350
+ self.to_pandas(),
351
+ values=column_name,
352
+ index="pile_tip_level_nap",
353
+ columns="test_id",
354
+ )
355
+ return results.sort_values("pile_tip_level_nap", ascending=False)
356
+
357
+ @lru_cache
358
+ def to_pandas(self) -> pd.DataFrame:
359
+ """Returns a total overview of all single-cpt results in a pandas.DataFrame representation."""
360
+ df_list: List[pd.DataFrame] = []
361
+
362
+ for test_id in self.cpt_results_dict:
363
+ df = self.cpt_results_dict[test_id].table.to_pandas()
364
+ df = df.assign(test_id=test_id)
365
+ df = df.assign(x=self.cpt_results_dict[test_id].soil_properties.x)
366
+ df = df.assign(y=self.cpt_results_dict[test_id].soil_properties.y)
367
+ df_list.append(df)
368
+
369
+ cpt_results_df = pd.concat(df_list)
370
+ cpt_results_df = cpt_results_df.assign(
371
+ pile_tip_level_nap=cpt_results_df.pile_tip_level_nap.round(1)
372
+ )
373
+
374
+ return cpt_results_df
375
+
376
+ @lru_cache()
377
+ def triangulation(self, pile_tip_level_nap: float) -> List[Dict[str, list]]:
378
+ """
379
+ Delaunay tessellation based on the CPT location
380
+
381
+ Returns
382
+ -------
383
+ collection: List
384
+ A list of dictionaries containing the tessellation
385
+ geometry and corresponding cpt names:
386
+
387
+ - geometry: List[Tuple[float, float]]
388
+ - test_id: List[str]
389
+
390
+ """
391
+ _lookup = {
392
+ (point.soil_properties.x, point.soil_properties.y): key
393
+ for key, point in self.cpt_results_dict.items()
394
+ }
395
+ # select point with valid bearing capacity at pile tip level
396
+ _points = (
397
+ self.to_pandas()
398
+ .loc[
399
+ (self.to_pandas()["pile_tip_level_nap"] == pile_tip_level_nap)
400
+ & (~pd.isna(self.to_pandas()["R_c_d_net"])),
401
+ ["x", "y"],
402
+ ]
403
+ .to_numpy()
404
+ .tolist()
405
+ )
406
+
407
+ # check if enough points Delaunay
408
+ if len(_points) < 4:
409
+ raise ValueError(
410
+ "Not enough points at this pile tip level to construct "
411
+ "the delaunay tessellation based on the CPT location."
412
+ )
413
+ tri = Delaunay(
414
+ _points,
415
+ incremental=False,
416
+ furthest_site=False,
417
+ qhull_options="Qbb",
418
+ )
419
+ geometries = np.array(_points)[tri.simplices]
420
+
421
+ return [
422
+ {
423
+ "geometry": geometry.tolist(),
424
+ "test_id": [_lookup[(xy[0], xy[1])] for xy in geometry],
425
+ }
426
+ for geometry in geometries
427
+ ]
428
+
429
+ def plot(
430
+ self,
431
+ projection: Optional[Literal["3d"]] = "3d",
432
+ hue: Literal["colormap", "category"] = "colormap",
433
+ pile_load_uls: float = 100,
434
+ figsize: Tuple[int, int] | None = None,
435
+ **kwargs: Any,
436
+ ) -> plt.Figure:
437
+ """
438
+ Plot a 3D scatterplot of the valid ULS load.
439
+
440
+ Parameters
441
+ ----------
442
+ projection
443
+ default is 3d
444
+ The projection type of the subplot. use None to create a 2D plot
445
+ hue
446
+ default is colormap
447
+ The marker colors methode. If colormap is used the colors represent the `R_c_d_net` value.
448
+ The category option sets the colors to valid ULS loads. Please use the pile_load_uls attribute to set
449
+ the required bearing capacity.
450
+ pile_load_uls
451
+ default is 100 kN
452
+ ULS load in kN. Used to determine if a pile tip level configuration is valid.
453
+ figsize:
454
+ Size of the activate figure, as the `plt.figure()` argument.
455
+ **kwargs:
456
+ All additional keyword arguments are passed to the `pyplot.subplots()` call.
457
+
458
+ Returns
459
+ -------
460
+ figure:
461
+ The `Figure` object where the data was plotted on.
462
+ """
463
+ kwargs_subplot = {
464
+ "figsize": figsize,
465
+ "tight_layout": True,
466
+ }
467
+
468
+ kwargs_subplot.update(kwargs)
469
+ fig = plt.figure(**kwargs_subplot)
470
+ axes = fig.add_subplot(projection=projection)
471
+ df = self.to_pandas().dropna()
472
+ # create color list based on hue option
473
+ if hue == "category":
474
+ colors = [
475
+ "red" if var < pile_load_uls else "green" for var in df["R_c_d_net"]
476
+ ]
477
+ else:
478
+ colors = df["R_c_d_net"].tolist()
479
+ # create scatter plot
480
+ if projection == "3d":
481
+ cmap = axes.scatter(
482
+ df["x"],
483
+ df["y"],
484
+ df["pile_tip_level_nap"],
485
+ c=colors,
486
+ )
487
+ axes.set_xlabel("X")
488
+ axes.set_ylabel("Y")
489
+ axes.set_zlabel("Z [m w.r.t NAP]")
490
+
491
+ # set cpt names
492
+ for key, result in self.cpt_results_dict.items():
493
+ axes.text(
494
+ result.soil_properties.x,
495
+ result.soil_properties.y,
496
+ result.table.pile_tip_level_nap.max(),
497
+ key,
498
+ "z",
499
+ )
500
+ else:
501
+ cmap = axes.scatter(
502
+ df["test_id"],
503
+ df["pile_tip_level_nap"],
504
+ c=colors,
505
+ )
506
+ axes.set_ylabel("Z [m w.r.t NAP]")
507
+ axes.tick_params(axis="x", labelrotation=90)
508
+ axes.grid()
509
+
510
+ if hue == "category":
511
+ fig.legend(
512
+ title="$R_{c;d;net}$ [kN]",
513
+ title_fontsize=18,
514
+ fontsize=15,
515
+ loc="lower right",
516
+ handles=[
517
+ patches.Patch(
518
+ facecolor=color,
519
+ label=label,
520
+ alpha=0.9,
521
+ linewidth=2,
522
+ edgecolor="black",
523
+ )
524
+ for label, color in zip(
525
+ [f">= {pile_load_uls}", f"< {pile_load_uls}"],
526
+ ["green", "red"],
527
+ )
528
+ ],
529
+ )
530
+ else:
531
+ fig.colorbar(cmap, orientation="vertical", label="$R_{c;d;net}$ [kN]")
532
+
533
+ return fig
534
+
535
+ def map(
536
+ self,
537
+ pile_tip_level_nap: float,
538
+ pile_load_uls: float = 100,
539
+ show_delaunay_vertices: bool = True,
540
+ show_voronoi_vertices: bool = False,
541
+ figsize: Tuple[int, int] | None = None,
542
+ **kwargs: Any,
543
+ ) -> plt.Figure:
544
+ """
545
+ Plot a map of the valid ULS load for a given depth.
546
+
547
+ Note
548
+ ------
549
+ Based on the Delaunay methode a tessellation is created with
550
+ the location of the CPT's. Each triangle is then colored according to
551
+ the bearing capacity of the CPT its based on. If any of the CPT does
552
+ not meet the required capacity the triangle becomes also invalid.
553
+
554
+ Warnings
555
+ --------
556
+ Please note that this map indication of valid ULS zones is intended as a visual aid to help
557
+ the geotechnical engineer. It does not necessarily comply with the NEN 9997-1+C2:2017 since the NEN is open
558
+ to interpretation. It is therefore that the interpretation provided by this methode must be carefully
559
+ validated by a geotechnical engineer.
560
+
561
+ Parameters
562
+ ----------
563
+ pile_tip_level_nap:
564
+ Pile tip level to generate map.
565
+ pile_load_uls
566
+ default is 100 kN
567
+ ULS load in kN. Used to determine if a pile tip level configuration is valid.
568
+ show_delaunay_vertices
569
+ default is True
570
+ Add delaunay vertices to the figure
571
+ show_voronoi_vertices
572
+ default is False
573
+ Add voronoi vertices to the figure
574
+ figsize:
575
+ Size of the activate figure, as the `plt.figure()` argument.
576
+ **kwargs:
577
+ All additional keyword arguments are passed to the `pyplot.subplots()` call.
578
+
579
+ Returns
580
+ -------
581
+ figure:
582
+ The `Figure` object where the data was plotted on.
583
+ """
584
+ kwargs_subplot = {
585
+ "figsize": figsize,
586
+ "tight_layout": True,
587
+ }
588
+
589
+ kwargs_subplot.update(kwargs)
590
+ fig, axes = plt.subplots(**kwargs_subplot)
591
+
592
+ # filter data
593
+ df = (
594
+ self.to_pandas()
595
+ .loc[self.to_pandas()["pile_tip_level_nap"] == pile_tip_level_nap]
596
+ .dropna()
597
+ )
598
+
599
+ if df.empty:
600
+ raise ValueError(
601
+ "Pile tip level is not valid pile tip level. "
602
+ "Please select one of the following pile tip level: "
603
+ f"[{(self.to_pandas()['pile_tip_level_nap']).unique()}]"
604
+ )
605
+
606
+ df["valid"] = [
607
+ False if var < pile_load_uls else True for var in df["R_c_d_net"]
608
+ ]
609
+
610
+ # iterate over geometry
611
+ if show_delaunay_vertices:
612
+ _patches = []
613
+ for tri in self.triangulation(pile_tip_level_nap):
614
+ color = (
615
+ "green"
616
+ if all(
617
+ df.where(df["test_id"].isin(tri["test_id"])).dropna()["valid"]
618
+ )
619
+ else "red"
620
+ )
621
+ _patches.append(
622
+ patches.Polygon(
623
+ np.array(tri["geometry"]), facecolor=color, edgecolor="grey"
624
+ )
625
+ )
626
+
627
+ collection = PatchCollection(_patches, match_original=True)
628
+ axes.add_collection(collection)
629
+
630
+ if show_voronoi_vertices:
631
+ points = [
632
+ (point.soil_properties.x, point.soil_properties.y)
633
+ for point in self.cpt_results_dict.values()
634
+ ]
635
+ vor = Voronoi(points)
636
+ voronoi_plot_2d(
637
+ vor,
638
+ show_vertices=False,
639
+ show_points=False,
640
+ ax=axes,
641
+ line_colors="black",
642
+ line_alpha=0.7,
643
+ line_width=0.1,
644
+ point_size=0.0,
645
+ )
646
+
647
+ # add the cpt names
648
+ axes.scatter(
649
+ df["x"],
650
+ df["y"],
651
+ c=["green" if val else "red" for val in df["valid"]],
652
+ )
653
+ for label, x, y in zip(df["test_id"], df["x"], df["y"]):
654
+ axes.annotate(label, xy=(x, y), xytext=(3, 3), textcoords="offset points")
655
+ axes.set_xlabel("X")
656
+ axes.set_ylabel("Y")
657
+ axes.ticklabel_format(useOffset=False)
658
+ fig.legend(
659
+ title="$R_{c;d;net}$ [kN]",
660
+ title_fontsize=18,
661
+ fontsize=15,
662
+ loc="lower right",
663
+ handles=[
664
+ patches.Patch(
665
+ facecolor=color,
666
+ label=label,
667
+ alpha=0.9,
668
+ linewidth=2,
669
+ edgecolor="black",
670
+ )
671
+ for label, color in zip(
672
+ [f">= {pile_load_uls}", f"< {pile_load_uls}"],
673
+ ["green", "red"],
674
+ )
675
+ ],
676
+ )
677
+ axes.set_title(f"Pile tip level at: {pile_tip_level_nap} [m w.r.t NAP]")
678
+
679
+ return fig