import copy
import logging
from typing import Dict, List, Optional, Union
import numpy as np
from PIL import ImageColor
from plotly import graph_objects as go
from plotly.graph_objects import Figure
from plotly.subplots import make_subplots
from geoprofile.constant import CODING_SOIL_TYPES
# optional import
try:
from pygef.bore import BoreData
from pygef.cpt import CptData
except ImportError:
CptData = None
BoreData = None
DEFAULT_COLUMN_WIDTH = 1
[docs]class Column:
[docs] def __init__(
self,
classify: Dict[str, List[Union[float, str]]],
x: float,
y: float,
name: str = "N/A",
z: Optional[float] = None,
groundwater_level: Optional[float] = None,
data: Optional[Dict[str, List[float]]] = None,
) -> None:
"""
Holds the data related to a column in the profile.
Parameters
----------
classify: dict
Dictionary that holds the classification data. Must
contain the following keys:
- depth
Top of the layers [m REF]
- thickness
Thickness of the layer [m]
- geotechnicalSoilName
Soil code of the layer related to the NEN-EN-ISO 14688-1:2019+NEN 8990:2020 Tabel NA.17
Other Parameters
-----------------
x: float
x coordinate of the column. Must be in a Cartesian coordinate system
y: float
x coordinate of the column. Must be in a Cartesian coordinate system
name: str, optional
default is N/A
Name of the location.
z: float, optional
Default is None
surface level of the column [m REF].
groundwater_level: float, optional
Default is None
groundwater level of the column [m REF].
data: dict
Default is None
Other data that can be added to the plot. If not None than dictionary
must contain the following key:
- depth
Top of the layers [m REF]
"""
# validate
if any(
[
key not in ["depth", "thickness", "geotechnicalSoilName"]
for key in classify.keys()
]
):
raise ValueError("Key missing in classify dictionary.")
if any(
[len(classify["depth"]) != len(classify[key]) for key in classify.keys()]
):
raise ValueError(
"Value arrays of classify dictionary do not have the same dimensions"
)
for key in classify["geotechnicalSoilName"]:
if key not in CODING_SOIL_TYPES.keys():
raise ValueError(
f"geotechnicalSoilName `{key}` not in CODING_SOIL_TYPES [NEN-EN-ISO 14688-1:2019+NEN 8990:2020 "
"Tabel NA.17] dictionary."
)
if data is not None:
if "depth" not in classify.keys():
raise ValueError("Key missing in data dictionary.")
if any(
[len(classify["depth"]) != len(classify[key]) for key in classify.keys()]
):
raise ValueError(
"Value arrays of data dictionary do not have the same dimensions"
)
self._classify = classify
self._x = x
self._y = y
self._z = z
self._groundwater_level = groundwater_level
self._name = name
# set optional attributes
if data is None:
data = {}
self._data = data
[docs] @classmethod
def from_cpt(cls, response: dict, gef: CptData) -> "Column":
"""
Use the cptcore response to translate the NEN6740 Table 2B to the NEN-EN-ISO 14688-1:2019+NEN 8990:2020 Tabel NA.17
Parameters
----------
response: dict
response of the CPT Core classify API call
gef: pygef.cpt.CptData
CPT object created by pygef.
Returns
-------
Column
"""
classify = {
"depth": gef.delivered_vertical_position_offset
- np.array(response.get("upperBoundary")),
"thickness": (
np.array(response.get("lowerBoundary"))
- np.array(response.get("upperBoundary"))
).tolist(),
"geotechnicalSoilName": [
name.split(";")[0].replace("*", "")
for name in response.get("geotechnicalSoilName", [])
],
}
if "depth" in gef.columns:
data = gef.data.drop("depth").rename({"depthOffset": "depth"}).to_dict()
else:
data = gef.data.rename({"depthOffset": "depth"}).to_dict()
return cls(
classify=classify,
x=gef.delivered_location.x,
y=gef.delivered_location.y,
z=gef.delivered_vertical_position_offset,
name=gef.alias if gef.bro_id is None else gef.bro_id,
groundwater_level=gef.groundwater_level_offset,
data=data,
)
[docs] @classmethod
def from_bore(cls, gef: BoreData) -> "Column":
"""
Transform the BoreData DataClass to our Column class
Parameters
----------
gef: pygef.bore.BoreData
Bore object created by pygef.
Returns
-------
Column
"""
classify = {
"depth": gef.data.get_column("upperBoundaryOffset").to_list(),
"thickness": (
gef.data.get_column("lowerBoundary")
- gef.data.get_column("upperBoundary")
).to_list(),
"geotechnicalSoilName": gef.data.get_column(
"geotechnicalSoilName"
).to_list(),
}
return cls(
classify=classify,
x=gef.delivered_location.x,
y=gef.delivered_location.y,
z=gef.delivered_vertical_position_offset,
name=gef.alias if gef.bro_id is None else gef.bro_id,
groundwater_level=gef.groundwater_level,
)
@property
def x(self) -> float:
"""x coordinate"""
return self._x
@property
def y(self) -> float:
"""y coordinate"""
return self._y
@property
def z(self) -> Optional[float]:
"""surface level [m REF]"""
return self._z
@property
def groundwater_level(self) -> Optional[float]:
"""groundwater level [m REF]"""
return self._groundwater_level
@property
def name(self) -> str:
"""column name"""
return self._name
@property
def classify(self) -> dict:
"""dictionary that holds the classification data"""
return self._classify
@property
def data(self) -> dict:
"""dictionary that holds the other data"""
return self._data
[docs] def plot(
self,
figure: Optional[Figure] = None,
hue: str = "percentage",
plot_kwargs: Optional[dict] = None,
x0: float = 0,
d_left: float = 0.5,
d_right: float = 0.5,
fillpattern: bool = True,
profile: bool = False,
) -> Figure:
"""
Create a plotly figure with the Soil Layout and the Data.
Parameters
----------
figure: Figure, optional
Default is None
A plotly graph object.
hue: str, optional
default is percentage
enum : ['percentage', 'uniform']
Show either the soil data in percentage or use a uniform color.
plot_kwargs: Dict, optional
Default is None
Dictionary with keys for properties to plot:
``` python
{
"qc": {
"line_color": "black",
"factor": 1,
},
"fr": {"line_color": "red"},
}
```
x0: float, optional
Default is 0
The x-coordinate of the start of the Soil Layout plot
d_left: float, optional
Default is 0.5
The width of the Soil Layout plot left of the center line.
Only used when heu is not percentage. If percentage the sum of
d_right and d_left is DEFAULT_COLUMN_WIDTH.
d_right: float, optional
Default is 0.5
The width of the Soil Layout plot right of the center line.
Only used when heu is not percentage. If percentage the sum of
d_right and d_left is DEFAULT_COLUMN_WIDTH.
fillpattern: bool, optional
Default is True
Fill the layers with the pattern related to the soil code of the
layer based on the NEN-EN-ISO 14688-1:2019+NEN 8990:2020 Tabel NA.17
profile: bool, optional
Default is False
Flag that indicates if plot is standalone or part on of a profile.
Returns
-------
Figure
"""
if hue not in ["percentage", "uniform"]:
raise ValueError("Invalid value for heu.")
# with of the column
dx = d_left + d_right
# end of the column
x1 = x0 + dx
# center of the column
x_center = x0 + d_left
if hue == "percentage":
# reset column to one meter width
dx = DEFAULT_COLUMN_WIDTH
x0 = x_center - dx / 2
if figure is None:
# initialize plot
figure = make_subplots(
rows=1,
cols=2,
y_title="Depth [m REF]",
shared_yaxes=True,
horizontal_spacing=0.01,
column_widths=[dx, 3.5],
subplot_titles=(
"Soil Layout",
"Data",
),
)
# Add bars for each soil type separately in order to be able to set legend labels
for key in np.unique(self.classify["geotechnicalSoilName"]):
# get index location of the soil code
select = np.array(self.classify["geotechnicalSoilName"]) == key
# illiterate of depth and add bar plot
for y0, dy in zip(
np.array(self.classify["depth"])[select],
np.array(self.classify["thickness"])[select],
):
# Based on the percentage of soil create the bar for every layer.
if hue == "percentage":
# start of the bar per color.
x0_color = copy.copy(x0)
for i, (color, percentage) in enumerate(
CODING_SOIL_TYPES[key]["color"].items()
):
# end of the bar per color.
x1_color = ((percentage / 100) * dx) + x0_color
figure.add_trace(
go.Scatter(
name=key,
x=[x0_color, x0_color, x1_color, x1_color, x0_color],
y=[y0, y0 - dy, y0 - dy, y0, y0],
text=(
f"Name: {self.name}<br>"
f"Soil Type: {key}<br>"
f"Top of layer: {y0:.2f}<br>"
f"Bottom of layer: {y0 - dy:.2f}<br>"
f"Thickness: {dy:.2f}"
),
hovertemplate="%{text}",
legendgroup=key,
mode="lines",
fill="toself",
fillcolor=color,
line=dict(color=color),
showlegend=key not in [i.name for i in figure.data],
fillpattern=(
CODING_SOIL_TYPES[key]["pattern"][i]
if fillpattern
else None
),
),
row=1,
col=1,
)
# reset the start of the bar color.
x0_color = x1_color
elif hue == "uniform":
# blend color based on the percentage
red = int(
sum(
[
ImageColor.getcolor(c, "RGB")[0] * p
for c, p in CODING_SOIL_TYPES[key]["color"].items()
]
)
/ 100
)
green = int(
sum(
[
ImageColor.getcolor(c, "RGB")[1] * p
for c, p in CODING_SOIL_TYPES[key]["color"].items()
]
)
/ 100
)
blue = int(
sum(
[
ImageColor.getcolor(c, "RGB")[2] * p # type: ignore
for c, p in CODING_SOIL_TYPES[key]["color"].items()
]
)
/ 100
)
color = "#%02x%02x%02x" % (red, green, blue)
figure.add_trace(
go.Scatter(
name=key,
x=[x0, x0, x1, x1, x0],
y=[y0, y0 - dy, y0 - dy, y0, y0],
text=(
f"Name: {self.name}<br>"
f"Soil Type: {key}<br>"
f"Top of layer: {y0:.2f}<br>"
f"Bottom of layer: {y0 - dy:.2f}<br>"
f"Thickness: {dy:.2f}"
),
hovertemplate="%{text}",
legendgroup=key,
mode="lines",
fill="toself",
fillcolor=color,
line=dict(color=color),
showlegend=key not in [i.name for i in figure.data],
fillpattern=(
CODING_SOIL_TYPES[key]["pattern"][0]
if fillpattern
else None
),
),
row=1,
col=1,
)
else:
raise ValueError
# plot other data
if plot_kwargs is not None:
for item in plot_kwargs.keys():
if item not in self.data.keys():
logging.warning(f"{item} not in data dictionary {self.name}.")
continue
# pop not needed plot kwargs
scatter_kwargs = copy.deepcopy(plot_kwargs)
if "factor" in scatter_kwargs[item].keys():
scatter_kwargs[item].pop("factor")
# add data to column
figure.add_trace(
go.Scatter(
name=item,
x=np.array(self.data[item])
* plot_kwargs[item].get("factor", 1.0)
+ x_center,
y=self.data["depth"],
customdata=self.data[item],
hovertemplate="%{customdata:.2f}, %{y:.2f}",
legendgroup=item,
showlegend=item not in [i.name for i in figure.data],
**scatter_kwargs[item],
),
row=1,
col=1 if profile else 2,
)
# Add dashed blue line representing phreatic level
if self.groundwater_level is not None and not profile:
figure.add_hline(
y=self.groundwater_level,
line=dict(color="Blue", dash="dash", width=1),
row="all",
col="all",
annotation_text=f"Phreatic level [m REF]: {self.groundwater_level:.2f}",
annotation_position="bottom right",
annotation_font_size=10,
annotation_font_color="black",
)
# Add dashed brown line representing ground level
if self.z is not None and not profile:
figure.add_hline(
y=self.z,
line=dict(color="Brown", dash="dash", width=1),
row="all",
col="all",
annotation_text=f"surface level [m REF]: {self.z:.2f}",
annotation_position="bottom right",
annotation_font_size=10,
annotation_font_color="black",
)
if not profile:
# update figure
figure.update_layout(title_text=f"Name: {self.name}")
figure.update_xaxes(row=1, col=1, showticklabels=False, visible=False)
if profile:
# add a vertical line to locate the column on the profile
figure.add_vline(
x=x_center,
line=dict(color="Black", dash="dash", width=1),
row="all",
col="all",
annotation_text=self.name,
annotation_position="top right",
annotation_font_size=10,
annotation_font_color="black",
annotation_textangle=90,
)
return figure