bedrock-ge 0.2.0__py3-none-any.whl → 0.2.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.
@@ -0,0 +1,26 @@
1
+ import pandas as pd
2
+
3
+
4
+ def check_ags_proj_group(ags_proj: pd.DataFrame) -> bool:
5
+ """Checks if the AGS 3 or AGS 4 PROJ group is correct.
6
+
7
+ Args:
8
+ proj_df (pd.DataFrame): The DataFrame with the PROJ group.
9
+
10
+ Raises:
11
+ ValueError: If AGS 3 of AGS 4 PROJ group is not correct.
12
+
13
+ Returns:
14
+ bool: Returns True if the AGS 3 or AGS 4 PROJ group is correct.
15
+ """
16
+
17
+ if len(ags_proj) != 1:
18
+ raise ValueError("The PROJ group must contain exactly one row.")
19
+
20
+ project_id = ags_proj["PROJ_ID"].iloc[0]
21
+ if not project_id:
22
+ raise ValueError(
23
+ 'The project ID ("PROJ_ID" in the "PROJ" group) is missing from the AGS data.'
24
+ )
25
+
26
+ return True
@@ -0,0 +1,36 @@
1
+ {
2
+ "Location": {
3
+ "attributes": {},
4
+ "geometry_type": "Point / 3D LineString",
5
+ "children": {
6
+ "MaterialClassification": {
7
+ "attributes": {},
8
+ "geometry_type": "3D LineString"
9
+ },
10
+ "SPT": {
11
+ "attributes": {},
12
+ "geometry_type": "3D Point"
13
+ },
14
+ "RQD": {
15
+ "attributes": {},
16
+ "geometry_type": "3D LineString"
17
+ },
18
+ "OtherInSituTests": {
19
+ "attributes": {},
20
+ "geometry_type": "3D Point or 3D LineString"
21
+ },
22
+ "Sample": {
23
+ "attributes": {},
24
+ "geometry_type": "3D Point",
25
+ "children": {
26
+ "grainSizeDistribution": {},
27
+ "atterbergLimits": {},
28
+ "oedometerTest": {},
29
+ "triaxialTest": {},
30
+ "unconfinedCompressiveStrength": {},
31
+ "otherLabTests": {}
32
+ }
33
+ }
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,38 @@
1
+ from typing import Dict, Union
2
+
3
+ import geopandas as gpd
4
+ import pandas as pd
5
+
6
+
7
+ def concatenate_databases(
8
+ db1: Dict[str, Union[pd.DataFrame, gpd.GeoDataFrame]],
9
+ db2: Dict[str, Union[pd.DataFrame, gpd.GeoDataFrame]],
10
+ ) -> Dict[str, pd.DataFrame]:
11
+ """
12
+ Concatenate two dictionaries of pandas dataframes into one dict of dfs.
13
+
14
+ The function concatenates the pandas dataframes of the second dict of dfs to the first dict of df for the keys that they have in common.
15
+ Keys that don't occur in either of the dict of dfs need to end up in the final dict of dfs.
16
+
17
+ Args:
18
+ db1 (Dict[str, pd.DataFrame]): A dictionary of pandas DataFrames, i.e. a database.
19
+ db2 (Dict[str, pd.DataFrame]): A dictionary of pandas DataFrames, i.e. a database.
20
+
21
+ Returns:
22
+ dict: A dictionary of concatenated pandas DataFrames.
23
+ """
24
+
25
+ # Create a new dict to store the concatenated dataframes
26
+ concatenated_dict = {key: df.dropna(axis=1, how="all") for key, df in db1.items()}
27
+
28
+ # Iterate over the keys in the second dict
29
+ for key, df in db2.items():
30
+ df = df.dropna(axis=1, how="all")
31
+ # If the key is also in the first dict, concatenate the dataframes
32
+ if key in db1:
33
+ concatenated_dict[key] = pd.concat([db1[key], df], ignore_index=True)
34
+ # If the key is not in the first dict, just add it to the new dict
35
+ else:
36
+ concatenated_dict[key] = df
37
+
38
+ return concatenated_dict
@@ -0,0 +1,235 @@
1
+ from typing import Dict, Tuple, Union
2
+
3
+ import geopandas as gpd
4
+ import numpy as np
5
+ import pandas as pd
6
+ from pyproj import Transformer
7
+ from pyproj.crs import CRS
8
+ from shapely.geometry import LineString, Point
9
+
10
+ # TODO: change function type hints, such that pandera checks the dataframes against the Bedrock schemas
11
+
12
+
13
+ def calculate_gis_geometry(
14
+ no_gis_brgi_db: Dict[str, Union[pd.DataFrame, gpd.GeoDataFrame]],
15
+ ) -> Dict[str, gpd.GeoDataFrame]:
16
+ # Make sure that the Bedrock database is not changed outside this function.
17
+ brgi_db = no_gis_brgi_db.copy()
18
+
19
+ print("Calculating GIS geometry for the Bedrock GI database tables...")
20
+
21
+ # Check if all projects have the same CRS
22
+ if not brgi_db["Project"]["crs_wkt"].nunique() == 1:
23
+ raise ValueError(
24
+ "All projects must have the same CRS (Coordinate Reference System).\n"
25
+ "Raise an issue on GitHub in case you need to be able to combine GI data that was acquired in multiple different CRS's."
26
+ )
27
+
28
+ crs = CRS.from_wkt(brgi_db["Project"]["crs_wkt"].iloc[0])
29
+
30
+ # Calculate GIS geometry for the 'Location' table
31
+ print("Calculating GIS geometry for the Bedrock GI 'Location' table...")
32
+ brgi_db["Location"] = calculate_location_gis_geometry(brgi_db["Location"], crs)
33
+
34
+ # Create the 'LonLatHeight' table.
35
+ # The 'LonLatHeight' table makes it easier to visualize the GIS geometry on 2D maps,
36
+ # because vertical lines are often very small or completely hidden in 2D.
37
+ # This table only contains the 3D of the GI locations at ground level,
38
+ # in WGS84 (Longitude, Latitude, Height) coordinates.
39
+ print(
40
+ "Creating 'LonLatHeight' table with GI locations in WGS84 geodetic coordinates...",
41
+ " WGS84 geodetic coordinates: (Longitude, Latitude, Ground Level Ellipsoidal Height)",
42
+ sep="\n",
43
+ )
44
+ brgi_db["LonLatHeight"] = create_lon_lat_height_table(brgi_db["Location"], crs)
45
+
46
+ # Create GIS geometry for tables that have In-Situ GIS geometry.
47
+ # These are the 'Sample' table and 'InSitu_...' tables.
48
+ # These tables are children of the Location table,
49
+ # i.e. have the 'Location' table as the parent table.
50
+ if "Sample" in brgi_db.keys():
51
+ print("Calculating GIS geometry for the Bedrock GI 'Sample' table...")
52
+ brgi_db["Sample"] = calculate_in_situ_gis_geometry(
53
+ brgi_db["Sample"], brgi_db["Location"], crs
54
+ )
55
+
56
+ for table_name, table in brgi_db.items():
57
+ if table_name.startswith("InSitu_"):
58
+ print(
59
+ f"Calculating GIS geometry for the Bedrock GI '{table_name}' table..."
60
+ )
61
+ brgi_db[table_name] = calculate_in_situ_gis_geometry(
62
+ table, brgi_db["Location"], crs
63
+ )
64
+
65
+ return brgi_db
66
+
67
+
68
+ def calculate_location_gis_geometry(
69
+ brgi_location: Union[pd.DataFrame, gpd.GeoDataFrame], crs: CRS
70
+ ) -> gpd.GeoDataFrame:
71
+ """
72
+ Calculate GIS geometry for a set of Ground Investigation locations.
73
+
74
+ Args:
75
+ brgi_location (Union[pd.DataFrame, gpd.GeoDataFrame]): The GI locations to calculate GIS geometry for.
76
+ crs (pyproj.CRS): The Coordinate Reference System (CRS) to use for the GIS geometry.
77
+
78
+ Returns:
79
+ gpd.GeoDataFrame: The GIS geometry for the given GI locations, with *additional* columns:
80
+ longitude: The longitude of the location in the WGS84 CRS.
81
+ latitude: The latitude of the location in the WGS84 CRS.
82
+ wgs84_ground_level_height: The height of the ground level of the location in the WGS84 CRS.
83
+ elevation_at_base: The elevation at the base of the location.
84
+ geometry: The GIS geometry of the location.
85
+ """
86
+ # Calculate Elevation at base of GI location
87
+ brgi_location["elevation_at_base"] = (
88
+ brgi_location["ground_level_elevation"] - brgi_location["depth_to_base"]
89
+ )
90
+
91
+ # Make a gpd.GeoDataFrame from the pd.DataFrame by creating GIS geometry
92
+ brgi_location = gpd.GeoDataFrame(
93
+ brgi_location,
94
+ geometry=brgi_location.apply(
95
+ lambda row: LineString(
96
+ [
97
+ (row["easting"], row["northing"], row["ground_level_elevation"]),
98
+ (row["easting"], row["northing"], row["elevation_at_base"]),
99
+ ]
100
+ ),
101
+ axis=1,
102
+ ),
103
+ crs=crs,
104
+ )
105
+
106
+ # Calculate WGS84 geodetic coordinates
107
+ brgi_location[["longitude", "latitude", "wgs84_ground_level_height"]] = (
108
+ brgi_location.apply(
109
+ lambda row: calculate_wgs84_coordinates(
110
+ from_crs=crs,
111
+ easting=row["easting"],
112
+ northing=row["northing"],
113
+ elevation=row["ground_level_elevation"],
114
+ ),
115
+ axis=1,
116
+ result_type="expand",
117
+ )
118
+ )
119
+
120
+ return brgi_location
121
+
122
+
123
+ def calculate_wgs84_coordinates(
124
+ from_crs: CRS, easting: float, northing: float, elevation: Union[float, None] = None
125
+ ) -> Tuple:
126
+ """Transform coordinates from an arbitrary Coordinate Reference System (CRS) to
127
+ the WGS84 CRS, which is the standard for geodetic coordinates.
128
+
129
+ Args:
130
+ from_crs (pyproj.CRS): The pyproj.CRS object of the CRS to transform from.
131
+ easting (float): The easting coordinate of the point to transform.
132
+ northing (float): The northing coordinate of the point to transform.
133
+ elevation (float or None, optional): The elevation of the point to
134
+ transform. Defaults to None.
135
+
136
+ Returns:
137
+ tuple: A tuple containing the longitude, latitude and WGS84 height of the
138
+ transformed point, in that order. The height is None if no elevation was
139
+ given, or if the provided CRS doesn't have a proper datum defined.
140
+ """
141
+ transformer = Transformer.from_crs(from_crs, 4326, always_xy=True)
142
+ if elevation:
143
+ lon, lat, wgs84_height = transformer.transform(easting, northing, elevation)
144
+ else:
145
+ lon, lat = transformer.transform(easting, northing)
146
+ wgs84_height = None
147
+
148
+ return lon, lat, wgs84_height
149
+
150
+
151
+ def create_lon_lat_height_table(
152
+ brgi_location: gpd.GeoDataFrame, crs: CRS
153
+ ) -> gpd.GeoDataFrame:
154
+ """Create a GeoDataFrame with GI locations in WGS84 (lon, lat, height) coordinates.
155
+
156
+ The 'LonLatHeight' table makes it easier to visualize the GIS geometry on 2D maps,
157
+ because vertical lines are often very small or completely hidden in 2D. This table
158
+ only contains the 3D point of the GI locations at ground level, in WGS84 (Longitude,
159
+ Latitude, Height) coordinates. Other attributes, such as the location type, sample
160
+ type, geology description, etc., can be attached to this table by joining, i.e.
161
+ merging those tables on the location_uid key.
162
+
163
+ Args:
164
+ brgi_location (GeoDataFrame): The GeoDataFrame with the GI locations.
165
+ crs (CRS): The Coordinate Reference System of the GI locations.
166
+
167
+ Returns:
168
+ GeoDataFrame: The 'LonLatHeight' GeoDataFrame.
169
+ """
170
+ lon_lat_height = gpd.GeoDataFrame(
171
+ brgi_location[
172
+ [
173
+ "project_uid",
174
+ "location_uid",
175
+ ]
176
+ ],
177
+ geometry=brgi_location.apply(
178
+ lambda row: Point(
179
+ row["longitude"], row["latitude"], row["wgs84_ground_level_height"]
180
+ ),
181
+ axis=1,
182
+ ),
183
+ crs=4326,
184
+ )
185
+ return lon_lat_height
186
+
187
+
188
+ def calculate_in_situ_gis_geometry(
189
+ brgi_in_situ: Union[pd.DataFrame, gpd.GeoDataFrame],
190
+ brgi_location: Union[pd.DataFrame, gpd.GeoDataFrame],
191
+ crs: CRS,
192
+ ) -> gpd.GeoDataFrame:
193
+ location_child = brgi_in_situ.copy()
194
+
195
+ # Merge the location data into the in-situ data to get the location coordinates
196
+ location_child = pd.merge(
197
+ location_child,
198
+ brgi_location[
199
+ ["location_uid", "easting", "northing", "ground_level_elevation"]
200
+ ],
201
+ on="location_uid",
202
+ how="left",
203
+ )
204
+
205
+ # Calculate the elevation at the top of the Sample or in-situ test
206
+ location_child["elevation_at_top"] = (
207
+ location_child["ground_level_elevation"] - location_child["depth_to_top"]
208
+ )
209
+ brgi_in_situ["elevation_at_top"] = location_child["elevation_at_top"]
210
+
211
+ # Calculate the elevation at the base of the Sample or in-situ test
212
+ if "depth_to_base" in location_child.columns:
213
+ location_child["elevation_at_base"] = (
214
+ location_child["ground_level_elevation"] - location_child["depth_to_base"]
215
+ )
216
+ brgi_in_situ["elevation_at_base"] = location_child["elevation_at_base"]
217
+
218
+ # Create the in-situ data as a GeoDataFrame with LineString GIS geometry for
219
+ # Samples or in-situ tests that have an elevation at the base of the Sample or in-situ test.
220
+ brgi_in_situ = gpd.GeoDataFrame(
221
+ brgi_in_situ,
222
+ geometry=location_child.apply(
223
+ lambda row: LineString(
224
+ [
225
+ (row["easting"], row["northing"], row["elevation_at_top"]),
226
+ (row["easting"], row["northing"], row["elevation_at_base"]),
227
+ ]
228
+ )
229
+ if "elevation_at_base" in row and not np.isnan(row["elevation_at_base"])
230
+ else Point((row["easting"], row["northing"], row["elevation_at_top"])),
231
+ axis=1,
232
+ ),
233
+ crs=crs,
234
+ )
235
+ return brgi_in_situ
@@ -0,0 +1,95 @@
1
+ """pandera schemas for Bedrock GI data. Base schemas refer to schemas that have no calculated GIS geometry or values."""
2
+
3
+ from typing import Optional
4
+
5
+ import pandera as pa
6
+ from pandera.typing import Series
7
+ from pandera.typing.geopandas import GeoSeries
8
+
9
+
10
+ class Project(pa.DataFrameModel):
11
+ project_uid: Series[str] = pa.Field(
12
+ # primary_key=True,
13
+ unique=True,
14
+ )
15
+ crs_wkt: Series[str] = pa.Field(description="Coordinate Reference System")
16
+ # datum: Series[str] = pa.Field(description="Datum used for measurement of the ground level elevation.")
17
+
18
+
19
+ class BaseLocation(pa.DataFrameModel):
20
+ location_uid: Series[str] = pa.Field(
21
+ # primary_key=True,
22
+ unique=True,
23
+ )
24
+ project_uid: Series[str] = pa.Field(
25
+ # foreign_key="project.project_uid"
26
+ )
27
+ location_source_id: Series[str]
28
+ location_type: Series[str]
29
+ easting: Series[float] = pa.Field(coerce=True)
30
+ northing: Series[float] = pa.Field(coerce=True)
31
+ ground_level_elevation: Series[float] = pa.Field(
32
+ coerce=True,
33
+ description="Elevation w.r.t. a local datum. Usually the orthometric height from the geoid, i.e. mean sea level, to the ground level.",
34
+ )
35
+ depth_to_base: Series[float]
36
+
37
+
38
+ class Location(BaseLocation):
39
+ elevation_at_base: Series[float]
40
+ longitude: Series[float]
41
+ latitude: Series[float]
42
+ wgs84_ground_level_height: Series[float] = pa.Field(
43
+ description="Ground level height w.r.t. the WGS84 (World Geodetic System 1984) ellipsoid.",
44
+ nullable=True,
45
+ )
46
+ geometry: GeoSeries
47
+
48
+
49
+ class BaseInSitu(pa.DataFrameModel):
50
+ project_uid: Series[str] = pa.Field(
51
+ # foreign_key="project.project_uid"
52
+ )
53
+ location_uid: Series[str] = pa.Field(
54
+ # foreign_key="location.location_uid"
55
+ )
56
+ depth_to_top: Series[float] = pa.Field(coerce=True)
57
+ depth_to_base: Optional[Series[float]] = pa.Field(coerce=True, nullable=True)
58
+
59
+
60
+ class BaseSample(BaseInSitu):
61
+ sample_uid: Series[str] = pa.Field(
62
+ # primary_key=True,
63
+ unique=True,
64
+ )
65
+ sample_source_id: Series[str]
66
+
67
+
68
+ class Sample(BaseSample):
69
+ elevation_at_top: Series[float]
70
+ elevation_at_base: Optional[Series[float]] = pa.Field(nullable=True)
71
+ geometry: GeoSeries
72
+
73
+
74
+ class InSitu(BaseInSitu):
75
+ elevation_at_top: Series[float]
76
+ elevation_at_base: Optional[Series[float]] = pa.Field(nullable=True)
77
+ geometry: GeoSeries
78
+
79
+
80
+ class BaseLab(pa.DataFrameModel):
81
+ project_uid: Series[str] = pa.Field(
82
+ # foreign_key="project.project_uid"
83
+ )
84
+ location_uid: Series[str] = pa.Field(
85
+ # foreign_key="location.location_uid"
86
+ )
87
+ sample_uid: Series[str] = pa.Field(
88
+ # foreign_key="sample.sample_uid"
89
+ )
90
+
91
+
92
+ class Lab(BaseLab):
93
+ geometry: GeoSeries = pa.Field(
94
+ description="GIS geometry of the sample on which this lab test was performed."
95
+ )
@@ -0,0 +1,74 @@
1
+ from typing import Optional
2
+
3
+ from sqlmodel import Field, SQLModel
4
+
5
+
6
+ class Project(SQLModel, table=True):
7
+ project_uid: str = Field(primary_key=True)
8
+ crs: str = Field(description="Coordinate Reference System")
9
+
10
+
11
+ class Location(SQLModel, table=True):
12
+ location_uid: str = Field(primary_key=True)
13
+ project_uid: str = Field(foreign_key="project.project_uid")
14
+ source_id: str
15
+ location_type: str
16
+ easting: float
17
+ northing: float
18
+ ground_level: float
19
+ depth_to_base: float
20
+ elevation_at_base: float
21
+ latitude: float
22
+ longitude: float
23
+
24
+
25
+ class DepthInformation(SQLModel):
26
+ depth_to_top: float
27
+ depth_to_base: Optional[float] = None
28
+ elevation_at_top: float
29
+ elevation_at_base: Optional[float] = None
30
+
31
+
32
+ class Sample(DepthInformation, table=True):
33
+ sample_uid: Optional[int] = Field(default=None, primary_key=True)
34
+ project_uid: str = Field(foreign_key="project.project_uid")
35
+ location_uid: str = Field(foreign_key="location.location_uid")
36
+ source_id: str
37
+
38
+
39
+ class InSitu(DepthInformation):
40
+ project_uid: str = Field(foreign_key="project.project_uid")
41
+ location_uid: str = Field(foreign_key="location.location_uid")
42
+
43
+
44
+ class Lab(SQLModel):
45
+ project_uid: str = Field(foreign_key="project.project_uid")
46
+ location_uid: str = Field(foreign_key="location.location_uid")
47
+ sample_uid: str = Field(foreign_key="sample.sample_uid")
48
+
49
+
50
+ class Material(InSitu, table=True):
51
+ """Material descriptions from the field. GEOL group in AGS 3 and AGS 4."""
52
+
53
+ id: Optional[int] = Field(default=None, primary_key=True)
54
+ material_name: str
55
+ material_description: Optional[str] = None
56
+
57
+
58
+ class SPT(InSitu, table=True):
59
+ id: Optional[int] = Field(default=None, primary_key=True)
60
+ spt_count: int
61
+
62
+
63
+ class RockCore(InSitu, table=True):
64
+ id: Optional[int] = Field(default=None, primary_key=True)
65
+ tcr: Optional[float] = Field(default=None, description="Total Core Recovery (%)")
66
+ scr: Optional[float] = Field(default=None, description="Solid Core Recovery (%)")
67
+ rqd: Optional[float] = Field(
68
+ default=None, description="Rock Quality Designation (%)"
69
+ )
70
+
71
+
72
+ class Weathering(InSitu, table=True):
73
+ id: Optional[int] = Field(default=None, primary_key=True)
74
+ weathering: str
@@ -0,0 +1,116 @@
1
+ from typing import Dict, Union
2
+
3
+ import geopandas as gpd # type: ignore
4
+ import pandas as pd
5
+
6
+ from bedrock_ge.gi.schemas import (
7
+ BaseInSitu,
8
+ BaseLocation,
9
+ BaseSample,
10
+ InSitu,
11
+ Location,
12
+ Project,
13
+ Sample,
14
+ )
15
+
16
+
17
+ def check_brgi_database(brgi_db: Dict):
18
+ for table_name, table in brgi_db.items():
19
+ if table_name == "Project":
20
+ Project.validate(table)
21
+ print("'Project' table aligns with Bedrock's 'Project' table schema.")
22
+ elif table_name == "Location":
23
+ Location.validate(table)
24
+ check_foreign_key("project_uid", brgi_db["Project"], table)
25
+ print("'Location' table aligns with Bedrock's 'Location' table schema.")
26
+ elif table_name == "Sample":
27
+ Sample.validate(table)
28
+ check_foreign_key("project_uid", brgi_db["Project"], table)
29
+ check_foreign_key("location_uid", brgi_db["Location"], table)
30
+ print("'Sample' table aligns with Bedrock's 'Sample' table schema.")
31
+ elif table_name == "InSitu":
32
+ InSitu.validate(table)
33
+ check_foreign_key("project_uid", brgi_db["Project"], table)
34
+ check_foreign_key("location_uid", brgi_db["Location"], table)
35
+ print(
36
+ f"'{table_name}' table aligns with Bedrock's table schema for In-Situ measurements."
37
+ )
38
+ elif table_name.startswith("Lab_"):
39
+ print(
40
+ "🚨 !NOT IMPLEMENTED! We haven't come across Lab data yet. !NOT IMPLEMENTED!"
41
+ )
42
+
43
+ return True
44
+
45
+
46
+ def check_no_gis_brgi_database(brgi_db: Dict):
47
+ for table_name, table in brgi_db.items():
48
+ if table_name == "Project":
49
+ Project.validate(table)
50
+ print("'Project' table aligns with Bedrock's 'Project' table schema.")
51
+ elif table_name == "Location":
52
+ BaseLocation.validate(table)
53
+ check_foreign_key("project_uid", brgi_db["Project"], table)
54
+ print(
55
+ "'Location' table aligns with Bedrock's 'Location' table schema without GIS geometry."
56
+ )
57
+ elif table_name == "Sample":
58
+ BaseSample.validate(table)
59
+ check_foreign_key("project_uid", brgi_db["Project"], table)
60
+ check_foreign_key("location_uid", brgi_db["Location"], table)
61
+ print(
62
+ "'Sample' table aligns with Bedrock's 'Sample' table schema without GIS geometry."
63
+ )
64
+ elif table_name.startswith("InSitu_"):
65
+ BaseInSitu.validate(table)
66
+ check_foreign_key("project_uid", brgi_db["Project"], table)
67
+ check_foreign_key("location_uid", brgi_db["Location"], table)
68
+ print(
69
+ f"'{table_name}' table aligns with Bedrock's '{table_name}' table schema without GIS geometry."
70
+ )
71
+ elif table_name.startswith("Lab_"):
72
+ print(
73
+ "🚨 !NOT IMPLEMENTED! We haven't come across Lab data yet. !NOT IMPLEMENTED!"
74
+ )
75
+
76
+ return True
77
+
78
+
79
+ def check_foreign_key(
80
+ foreign_key: str,
81
+ parent_table: Union[pd.DataFrame, gpd.GeoDataFrame],
82
+ table_with_foreign_key: Union[pd.DataFrame, gpd.GeoDataFrame],
83
+ ) -> bool:
84
+ """
85
+ Checks if a foreign key in a table exists in the parent table.
86
+
87
+ Foreign keys describe the relationship between tables in a relational database.
88
+ For example, all GI Locations belong to a project.
89
+ All GI Locations are related to a project with the project_uid (Project Unique IDentifier).
90
+ The project_uid is the foreign key in the Location table.
91
+ This implies that the project_uid in the foreign key in the Location table must exist in the Project parent table.
92
+ That is what this function checks.
93
+
94
+ Args:
95
+ foreign_key (str): The name of the column of the foreign key.
96
+ parent_table (Union[pd.DataFrame, gpd.GeoDataFrame]): The parent table.
97
+ table_with_foreign_key (Union[pd.DataFrame, gpd.GeoDataFrame]): The table with the foreign key.
98
+
99
+ Raises:
100
+ ValueError: If the table with the foreign key contains foreign keys that don't occur in the parent table.
101
+
102
+ Returns:
103
+ bool: True if the foreign keys all exist in the parent table.
104
+ """
105
+ # Get the foreign keys that are missing in the parent group
106
+ missing_foreign_keys = table_with_foreign_key[
107
+ ~table_with_foreign_key[foreign_key].isin(parent_table[foreign_key])
108
+ ]
109
+
110
+ # Raise an error if there are missing foreign keys
111
+ if len(missing_foreign_keys) > 0:
112
+ raise ValueError(
113
+ f"This table contains '{foreign_key}'s that don't occur in the parent table:\n{missing_foreign_keys}"
114
+ )
115
+
116
+ return True