Repository: swisstopo/swissgeol-boreholes-dataextraction

Issue:
Find an "intelligent" way of associating boreholes element to the correct borehole profile
Issue: Unreliable Assignment of Extracted Data to Borehole Profiles in util/predictions.py

```    def build(self) -> list[BoreholePredictions]:
        """Creates a list of BoreholePredictions after ensuring all lists have the same length."""
        self._extend_length_to_match_boreholes_num_pred()

        return [
            BoreholePredictions(
                borehole_index,
                layers_in_borehole,
                self._file_name,
                BoreholeMetadata(elevation, coordinate),
                groundwater,
                bounding_boxes,
            )
            for borehole_index, (
                layers_in_borehole,
                groundwater,
                bounding_boxes,
                elevation,
                coordinate,
            ) in enumerate(
                zip(
                    self._layers_in_document.boreholes_layers,
                    self._groundwater_in_doc.borehole_groundwaters,
                    self._bounding_boxes,
                    self._elevations_list,
                    self._coordinates_list,
                    strict=True,
                )
            )
        ]
```

Context:
This issue was initially raised in Pull Request #150, which aimed to resolve Issue#134: multiple borehole per document.

Problem:
After the extraction process, multiple lists of elements (e.g., elevations, bounding boxes, lists of layers, etc.) are zipped together and assigned to borehole profiles based on their order. However, this approach is not robust, as the extraction order does not guarantee that elements correspond correctly to the intended borehole profiles.

Expected Behavior:
The extracted elements should be reliably mapped to their corresponding borehole profiles based on explicit identifiers rather than relying on list order.

Possible Solution:
- Validate the extracted data with the position of the elements to ensure correctness before association.

Hints from issue comments:
@stijnvermeeren-swisstopo: Acceptance criteria:
- Remove the TODO from https://github.com/swisstopo/swissgeol-boreholes-dataextraction/pull/152/commits/09cb9e0d2bcb5b4f230078cbf2742b240ebdf177#diff-095ddac8b617b0bf2dcb5e1e683e22de37be9b1604f6a41535f73424fde82905R270
- Improved accuracy for coordinates for 269126143-bp.pdf
- Improved accuracy for elevation for 267125358-bp.pdf
- Scores not worse for any other input files

Solution patch:
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 11b61972..a3a3228e 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -43,6 +43,18 @@
             "justMyCode": true,
             "python": "${workspaceFolder}/swisstopo/bin/python3",
         },
+        {
+            "name": "Python: Run boreholes-classify-descriptions",
+            "type": "debugpy",
+            "request": "launch",
+            "module": "src.description_classification.main",
+            "args": [
+                "-f", "data/geoquat_ground_truth.json",
+            ],
+            "cwd": "${workspaceFolder}",
+            "justMyCode": true,
+            "python": "${workspaceFolder}/swisstopo/bin/python3",
+        },
         {
             "name": "API",
             "type": "debugpy",
diff --git a/README.predictions-json.md b/README.predictions-json.md
index 306e2260..ea8862e5 100644
--- a/README.predictions-json.md
+++ b/README.predictions-json.md
@@ -2,24 +2,26 @@
 The `predictions.json` file contains the results of a data extraction process in a machine-readable format. By default, the file is written to `data/output/predictions.json`.
 
 Each key in the JSON object is the name of a PDF file. The extracted data is listed as an object with the following keys:
-- `metadata`
-  - `elevation`: the detected elevation (if any) and the location in the PDF where they were extraction from.
-  - `coordinates`: the detected coordinates (if any) and the location in the PDF where they were extraction from.
-  - `language`: language that was detected for the document.
-  - `page_dimensions`: dimensions of each page in the PDF, measured in PDF points
-- `layers`: a list of objects, where each object represents a layer of the borehole profile, using the following keys:
-  - `material_description`: the text of the material description, both as a single value as well as line-by-line, and the location in the PDF where the text resp. the lines where extracted from.
-  - `depth_interval`: the measured depth of the upper and lower limits of the layer, and the location in the PDF where they were extracted from.
-- `bounding_boxes`: a list of objects, one for each (part of a) borehole profile in the PDF, that list some bounding boxes that can be used for visualizations. Each object has the following keys:
-  - `sidebar_rect`: the area of the page the contains a "sidebar" (if any), which contains depths or other data displayed to the side of material descriptions.
-  - `depth_column_entries`: list of locations of the entries in the depth column (if any).
-  - `material_description_rect`: the area of the page that contains all material descriptions.
-  - `page`: the number of the page of the PDF.
-- `groundwater`: a list of objects, one for each groundwater measurement that was extracted from the PDF. Each object has the following keys.
-  - `date`: extracted date for the groundwater measurement (if any) as a string in YYYY-MM-DD format.
-  - `depth`: the measured depth (in m) of the groundwater measurement.
-  - `elevation`: the elevation (in m above sea level) of the groundwater measurement.
-  - `page` and `rect`: the location in the PDF where the groundwater measurement was extracted from.
+- `language`: language that was detected for the document.
+- `page_dimensions`: dimensions of each page in the PDF, measured in PDF points
+- `boreholes`: list of all boreholes identified on the documents. Each borehole object has the following keys.
+  - `borehole_index`: index to differenciate the extracted boreholes, starting at 0.
+  - `metadata`
+    - `elevation`: the detected elevation (if any) and the location in the PDF where they were extraction from.
+    - `coordinates`: the detected coordinates (if any) and the location in the PDF where they were extraction from.
+  - `layers`: a list of objects, where each object represents a layer of the borehole profile, using the following keys:
+    - `material_description`: the text of the material description, both as a single value as well as line-by-line, and the location in the PDF where the text resp. the lines where extracted from.
+    - `depth_interval`: the measured depth of the upper and lower limits of the layer, and the location in the PDF where they were extracted from.
+  - `bounding_boxes`: a list of objects, one for each (part of a) borehole profile in the PDF, that list some bounding boxes that can be used for visualizations. Each object has the following keys:
+    - `sidebar_rect`: the area of the page the contains a "sidebar" (if any), which contains depths or other data displayed to the side of material descriptions.
+    - `depth_column_entries`: list of locations of the entries in the depth column (if any).
+    - `material_description_rect`: the area of the page that contains all material descriptions.
+    - `page`: the number of the page of the PDF.
+  - `groundwater`: a list of objects, one for each groundwater measurement that was extracted from the PDF. Each object has the following keys.
+    - `date`: extracted date for the groundwater measurement (if any) as a string in YYYY-MM-DD format.
+    - `depth`: the measured depth (in m) of the groundwater measurement.
+    - `elevation`: the elevation (in m above sea level) of the groundwater measurement.
+    - `page` and `rect`: the location in the PDF where the groundwater measurement was extracted from.
 
 All page numbers are counted starting at 1.
 
@@ -29,99 +31,105 @@ All bounding boxes are measured with PDF points as the unit, and with the top-le
 ```yaml
 {
   "B366.pdf": {  # file name
-    "metadata": {
-      "elevation": {
-        "elevation": 355.35,
-        "page": 1,
-        "rect": [27.49843978881836, 150.2817840576172, 159.42971801757812, 160.76754760742188]
-      },
-      "coordinates": {
-        "E": 659490.0,
-        "N": 257200.0,
-        "rect": [28.263830184936523, 179.63882446289062, 150.3379364013672, 188.7487335205078],
-        "page": 1
+    "language": "de", 
+    "page_dimensions": [
+      {
+        "width": 591.956787109375,
+        "height": 1030.426025390625
       },
-      "language": "de", 
-      "page_dimensions": [
-        {
-          "width": 591.956787109375,
-          "height": 1030.426025390625
-        },
-        {
-          "width": 588.009521484375,
-          "height": 792.114990234375
-        }
-      ]
-    },
-    "layers": [
       {
-        "material_description": {
-          "text": "beiger, massig-dichter, stark dolomitisierter Kalk, mit Muschelresten",
-          "lines": [
-            {
-              "text": "beiger, massig-dichter, stark",
-              "page": 1,
-              "rect": [258.5303039550781, 345.9997253417969, 379.9410705566406, 356.1011657714844]
-            },
-            {
-              "text": "dolomitisierter Kalk, mit",
+        "width": 588.009521484375,
+        "height": 792.114990234375
+      }
+    ],
+    "boreholes": [
+      {
+        "borehole_index": 0,
+        "metadata": {
+          "elevation": {
+            "elevation": 355.35,
+            "page": 1,
+            "rect": [27.49843978881836, 150.2817840576172, 159.42971801757812, 160.76754760742188]
+          },
+          "coordinates": {
+            "E": 659490.0,
+            "N": 257200.0,
+            "rect": [28.263830184936523, 179.63882446289062, 150.3379364013672, 188.7487335205078],
+            "page": 1
+          },
+        },
+        "layers": [
+          {
+            "material_description": {
+              "text": "beiger, massig-dichter, stark dolomitisierter Kalk, mit Muschelresten",
+              "lines": [
+                {
+                  "text": "beiger, massig-dichter, stark",
+                  "page": 1,
+                  "rect": [258.5303039550781, 345.9997253417969, 379.9410705566406, 356.1011657714844]
+                },
+                {
+                  "text": "dolomitisierter Kalk, mit",
+                  "page": 1,
+                  "rect": [258.2362060546875, 354.4559326171875, 363.0706787109375, 364.295654296875]
+                },
+                {
+                  "text": "Muschelresten",
+                  "page": 1,
+                  "rect": [258.48748779296875, 363.6712341308594, 313.03204345703125, 371.3343505859375]
+                }
+              ],
               "page": 1,
-              "rect": [258.2362060546875, 354.4559326171875, 363.0706787109375, 364.295654296875]
+              "rect": [258.2362060546875, 345.9997253417969, 379.9410705566406, 371.3343505859375]
             },
-            {
-              "text": "Muschelresten",
-              "page": 1,
-              "rect": [258.48748779296875, 363.6712341308594, 313.03204345703125, 371.3343505859375]
+            "depth_interval": {
+              "start": {
+                "value": 1.5,
+                "rect": [200.63790893554688, 331.3035888671875, 207.83108520507812, 338.30450439453125]
+              },
+              "end": {
+                "value": 6.0,
+                "rect": [201.62551879882812, 374.30560302734375, 210.0361328125, 380.828857421875]
+              }
             }
-          ],
-          "page": 1,
-          "rect": [258.2362060546875, 345.9997253417969, 379.9410705566406, 371.3343505859375]
-        },
-        "depth_interval": {
-          "start": {
-            "value": 1.5,
-            "rect": [200.63790893554688, 331.3035888671875, 207.83108520507812, 338.30450439453125]
           },
-          "end": {
-            "value": 6.0,
-            "rect": [201.62551879882812, 374.30560302734375, 210.0361328125, 380.828857421875]
+          # ... (more layers)
+        ],
+        "bounding_boxes": [
+          {
+            "sidebar_rect": [198.11251831054688, 321.8956298828125, 210.75906372070312, 702.2628173828125],
+            "depth_column_entries": [
+              [200.1201171875, 321.8956298828125, 208.59901428222656, 328.6802062988281],
+              [200.63790893554688, 331.3035888671875, 207.83108520507812, 338.30450439453125],
+              [201.62551879882812, 374.30560302734375, 210.0361328125, 380.828857421875],
+              [199.86251831054688, 434.51556396484375, 210.10894775390625, 441.4538879394531],
+              [198.11251831054688, 557.5472412109375, 210.35877990722656, 563.9244995117188],
+              [198.28451538085938, 582.0216674804688, 209.76953125, 588.7603759765625],
+              [198.7814178466797, 616.177001953125, 209.50042724609375, 622.502197265625],
+              [198.6378173828125, 663.2830810546875, 210.75906372070312, 669.5428466796875],
+              [198.26901245117188, 695.974609375, 209.12693786621094, 702.2628173828125]
+            ],
+            "material_description_rect": [256.777099609375, 345.9997253417969, 392.46051025390625, 728.2700805664062],
+            "page": 1
+          },
+          {
+            "sidebar_rect": null,
+            "depth_column_entries": [],
+            "material_description_rect": [192.3216094970703, 337.677978515625, 291.1827392578125, 633.6331176757812],
+            "page": 2
           }
-        }
-      },
-      # ... (more layers)
-    ],
-    "bounding_boxes": [
-      {
-        "sidebar_rect": [198.11251831054688, 321.8956298828125, 210.75906372070312, 702.2628173828125],
-        "depth_column_entries": [
-          [200.1201171875, 321.8956298828125, 208.59901428222656, 328.6802062988281],
-          [200.63790893554688, 331.3035888671875, 207.83108520507812, 338.30450439453125],
-          [201.62551879882812, 374.30560302734375, 210.0361328125, 380.828857421875],
-          [199.86251831054688, 434.51556396484375, 210.10894775390625, 441.4538879394531],
-          [198.11251831054688, 557.5472412109375, 210.35877990722656, 563.9244995117188],
-          [198.28451538085938, 582.0216674804688, 209.76953125, 588.7603759765625],
-          [198.7814178466797, 616.177001953125, 209.50042724609375, 622.502197265625],
-          [198.6378173828125, 663.2830810546875, 210.75906372070312, 669.5428466796875],
-          [198.26901245117188, 695.974609375, 209.12693786621094, 702.2628173828125]
         ],
-        "material_description_rect": [256.777099609375, 345.9997253417969, 392.46051025390625, 728.2700805664062],
-        "page": 1
+        "groundwater": [
+          {
+            "date": "1979-11-29",
+            "depth": 19.28,
+            "elevation": 336.07,
+            "page": 1,
+            "rect": [61.23963928222656, 489.3185119628906, 94.0096435546875, 513.6478881835938]
+          }
+        ]
       },
-      {
-        "sidebar_rect": null,
-        "depth_column_entries": [],
-        "material_description_rect": [192.3216094970703, 337.677978515625, 291.1827392578125, 633.6331176757812],
-        "page": 2
-      }
-    ],
-    "groundwater": [
-      {
-        "date": "1979-11-29",
-        "depth": 19.28,
-        "elevation": 336.07,
-        "page": 1,
-        "rect": [61.23963928222656, 489.3185119628906, 94.0096435546875, 513.6478881835938]
-      }
+      # ... (more boreholes)
     ]
   }
 }
diff --git a/config/classification_params.yml b/config/classification_params.yml
new file mode 100644
index 00000000..a97a078f
--- /dev/null
+++ b/config/classification_params.yml
@@ -0,0 +1,165 @@
+default_language: "de"
+supported_language: ["de","fr","en","it"]
+uscs_patterns:
+  de:
+    kunst: "künstliche Auffüllung"
+    Bl: "Block (Bl)"
+    GP: "schlecht abgestufter Kies (GP)"
+    CH: "fetter Ton (CH)"
+    CM: "magerer Ton (CM)"
+    CL: "siltiger Ton (CL)"
+    CL-ML: "toniger Silt (CL-ML)"
+    G: "Kies (G)"
+    S: "Sand (S)"
+    GW-GC: "gut abgestufter Kies mit Ton (GW-GC)"
+    Pt: "Torf (Pt)"
+    ML: "Silt (ML)"
+    GM: "siltiger Kies (GM)"
+    kA: "keine Angabe"
+    FELS: "Fels"
+    SC: "toniger Sand (SC)"
+    S-SM: "schwach siltiger Sand (S-SM)"
+    SM: "siltiger Sand (SM)"
+    SP: "schlecht abgestufter Sand (SP)"
+    SP-SC: "schlecht abgestufter Sand mit Ton (SP-SC)"
+    SP-SM: "schlecht abgestufter Sand mit Silt (SP-SM)"
+    SW: "gut abgestufter Sand (SW)"
+    SW-SC: "gut abgestufter Sand mit Ton (SW-SC)"
+    SW-SM: "gut abgestufter Sand mit Silt (SW-SM)"
+    G-GC: "schwach toniger Kies (G-GC)"
+    G-GM: "schwach siltiger Kies (G-GM)"
+    St: "Stein (St)"
+    St-Bl: "Steine und Blöcke (St-Bl)"
+    OH: "organischer Silt (OH)"
+    OL: "organischer Ton (OL)"
+    S-SC: "schwach toniger Sand (S-SC)"
+    SC-SM: "siltiger bis toniger Sand (SC-SM)"
+    GC: "toniger Kies (GC)"
+    GC-GM: "siltiger bis toniger Kies (GC-GM)"
+    GP-GC: "schlecht abgestufter Kies mit Ton (GP-GC)"
+    GP-GM: "schlecht abgestufter Kies mit Silt (GP-GM)"
+    GW: "gut abgestufter Kies (GW)"
+    GW-GM: "gut abgestufter Kies mit Silt (GW-GM)"
+    MH: "elastischer Silt (MH)"
+  fr:
+    kunst: "remplissage artificiel"
+    Bl: "bloc (Bl)"
+    GP: "gravier mal gradué (GP)"
+    CH: "argile grasse (CH)"
+    CM: "argile maigre (CM)"
+    CL: "argile limoneuse (CL)"
+    CL-ML: "limon argileux (CL-ML)"
+    G: "gravier (G)"
+    S: "sable (S)"
+    GW-GC: "gravier bien gradué avec argile (GW-GC)"
+    Pt: "tourbe (Pt)"
+    ML: "limon (ML)"
+    GM: "gravier limoneux (GM)"
+    kA: "sans indication"
+    FELS: "rocher"
+    SC: "sable argileux (SC)"
+    S-SM: "sable peu limoneux (S-SM)"
+    SM: "sable limoneux (SM)"
+    SP: "sable mal gradué (SP)"
+    SP-SC: "sable mal gradué avec argile (SP-SC)"
+    SP-SM: "sable mal gradué avec limon (SP-SM)"
+    SW: "sable bien gradué (SW)"
+    SW-SC: "sable bien gradué avec argile (SW-SC)"
+    SW-SM: "sable bien gradué avec limon (SW-SM)"
+    G-GC: "gravier peu argileux (G-GC)"
+    G-GM: "gravier peu limoneux (G-GM)"
+    St: "pierre (St)"
+    St-Bl: "pierre et bloc (St-Bl)"
+    OH: "limon organique (OH)"
+    OL: "argile organique (OL)"
+    S-SC: "sable peu argileux (S-SC)"
+    SC-SM: "sable limono-argileux (SC-SM)"
+    GC: "gravier argileux (GC)"
+    GC-GM: "gravier limono-argileux (GC-GM)"
+    GP-GC: "gravier mal gradué avec argile (GP-GC)"
+    GP-GM: "gravier mal gradué avec limon (GP-GM)"
+    GW: "gravier bien gradué (GW)"
+    GW-GM: "gravier bien gradué avec limon (GW-GM)"
+    MH: "limon élastique (MH)"
+
+  en:
+    kunst: "artificial landfill"
+    Bl: "boulder (Bl)"
+    GP: "poorly graded gravel (GP)"
+    CH: "fat clay (CH)"
+    CM: "lean clay (CM)"
+    CL: "silty clay (CL)"
+    CL-ML: "clayey silt (CL-ML)"
+    G: "gravel (G)"
+    S: "sand (S)"
+    GW-GC: "well-graded gravel with clay (GW-GC)"
+    Pt: "peat (Pt)"
+    ML: "silt (ML)"
+    GM: "silty gravel (GM)"
+    kA: "not specified"
+    FELS: "bed rock"
+    SC: "clayey sand (SC)"
+    S-SM: "slightly silty sand (S-SM)"
+    SM: "silty sand (SM)"
+    SP: "poorly graded sand (SP)"
+    SP-SC: "poorly graded sand with clay (SP-SC)"
+    SP-SM: "poorly graded sand with silt (SP-SM)"
+    SW: "well-graded sand (SW)"
+    SW-SC: "well-graded sand with clay (SW-SC)"
+    SW-SM: "well-graded sand with silt (SW-SM)"
+    G-GC: "slightly clayey gravel (G-GC)"
+    G-GM: "slightly silty gravel (G-GM)"
+    St: "cobbles (St)"
+    St-Bl: "cobbles and boulders (St-Bl)"
+    OH: "organic silt (OH)"
+    OL: "organic clay (OL)"
+    S-SC: "slightly clayey sand (S-SC)"
+    SC-SM: "silty, clayey sand (SC-SM)"
+    GC: "clayey gravel (GC)"
+    GC-GM: "silty, clayey gravel (GC-GM)"
+    GP-GC: "poorly graded gravel with clay (GP-GC)"
+    GP-GM: "poorly graded gravel with silt (GP-GM)"
+    GW: "well-graded gravel (GW)"
+    GW-GM: "well-graded gravel with silt (GW-GM)"
+    MH: "elastic silt (MH)"
+  it:
+    kunst: "riempimento artificiale"
+    Bl: "blocco (Bl)"
+    GP: "ghiaia mal classata (GP)"
+    CH: "argilla grassa (CH)"
+    CM: "argilla magra (CM)"
+    CL: "argilla limosa (CL)"
+    CL-ML: "limo argilloso (CL-ML)"
+    G: "ghiaia (G)"
+    S: "sabbia (S)"
+    GW-GC: "ghiaia ben con argilla (GW-GC)"
+    Pt: "torba (Pt)"
+    ML: "limo (ML)"
+    GM: "ghiaia limosa (GM)"
+    kA: "senza indicazioni"
+    FELS: "roccia"
+    SC: "sabbia argillosa (SC)"
+    S-SM: "sabbia leggermente limosa (S-SM)"
+    SM: "sabbia limosa (SM)"
+    SP: "sabbia mal classata (SP)"
+    SP-SC: "sabbia mal classata con argilla (SP-SC)"
+    SP-SM: "sabbia mal classata con limo (SP-SM)"
+    SW: "sabbia ben classata (SW)"
+    SW-SC: "sabbia ben classata con argilla (SW-SC)"
+    SW-SM: "sabbia ben classata con limo (SW-SM)"
+    G-GC: "ghiaia leggermente argillosa (G-GC)"
+    G-GM: "ghiaia leggermente limosa (G-GM)"
+    St: "pietra (St)"
+    St-Bl: "pietre e blocchi (St-Bl)"
+    OH: "limo organico (OH)"
+    OL: "argilla organica (OL)"
+    S-SC: "sabbia leggermente argillosa (S-SC)"
+    SC-SM: "sabbia limo argillosa (SC-SM)"
+    GC: "ghiaia argillosa (GC)"
+    GC-GM: "ghiaia limoso argillosa (GC-GM)"
+    GP-GC: "ghiaia mal classata con argilla (GP-GC)"
+    GP-GM: "ghiaia mal classata con limo (GP-GM)"
+    GW: "ghiaia ben classata (GW)"
+    GW-GM: "ghiaia ben con limo (GW-GM)"
+    MH: "limo elastico (MH)"
+
diff --git a/pyproject.toml b/pyproject.toml
index 7a53eda5..3cae833e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -66,6 +66,7 @@ boreholes-extract-all = "stratigraphy.main:click_pipeline"
 boreholes-extract-metadata = "stratigraphy.main:click_pipeline_metadata"
 boreholes-download-profiles = "stratigraphy.get_files:download_directory_froms3"
 boreholes-score = "stratigraphy.benchmark.score:main"
+boreholes-classify-descriptions = "description_classification.main:click_pipeline"
 
 [tool.ruff.lint]
 select = [
@@ -94,4 +95,14 @@ ignore = [
 line-length = 119
 
 [tool.ruff.lint.pydocstyle]
-convention = "google"
\ No newline at end of file
+convention = "google"
+
+[tool.pytest.ini_options]
+# Some deprecation warnings come from external libraries (botocore and SWIG). Since we can’t fix these in our code,
+# they are now ignored during test runs to keep the output clean. Link to the existing issues:
+# SWIG: https://github.com/pymupdf/PyMuPDF/issues/3931
+# boto3: https://github.com/boto/boto3/issues/3889
+filterwarnings = [
+    "ignore:.*Swig.*has no __module__ attribute:DeprecationWarning",
+    "ignore:datetime.datetime.utcnow:DeprecationWarning:botocore"
+]
\ No newline at end of file
diff --git a/src/app/common/schemas.py b/src/app/common/schemas.py
index 96654619..1346e0ec 100644
--- a/src/app/common/schemas.py
+++ b/src/app/common/schemas.py
@@ -13,7 +13,7 @@
 from pathlib import Path
 
 import fitz
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, ConfigDict, Field, field_validator
 
 
 def validate_filename(value: str) -> str:
@@ -38,17 +38,14 @@ class PNGRequest(BaseModel):
 
     filename: Path  # This will ensure the filename is a Path object
 
+    model_config = ConfigDict(arbitrary_types_allowed=True)  # This allows using non-standard types like Path
+
     @field_validator("filename", mode="before")
     @classmethod
     def validate_filename(cls, value: str) -> str:
         """Ensure the filename is not empty."""
         return validate_filename(value)
 
-    class Config:
-        """Make to allow using non-standard types like Path."""
-
-        arbitrary_types_allowed: bool = True  # This allows using non-standard types like Path
-
 
 class PNGResponse(BaseModel):
     """Response schema for the `create_pngs` endpoint, representing the output of PNG file creation and storage.
@@ -61,7 +58,13 @@ class PNGResponse(BaseModel):
         ...,
         description="""List of unique identifiers (keys) for the generated PNG files stored in the S3 bucket. Each key 
         allows access to a specific file within the bucket.""",
-        example=["dataextraction/file1-1.png", "dataextraction/file1-2.png", "dataextraction/file1-3.png"],
+    )
+    model_config = ConfigDict(
+        json_schema_extra={
+            "example": {
+                "keys": ["dataextraction/file1-1.png", "dataextraction/file1-2.png", "dataextraction/file1-3.png"]
+            },
+        }
     )
 
 
@@ -90,27 +93,25 @@ class BoundingBox(BaseModel):
         ...,
         description="""The x-coordinate of the top-left corner of the bounding box. This value marks the 
         horizontal starting point of the box.""",
-        example=0.0,
     )
     y0: float = Field(
         ...,
         description="""The y-coordinate of the top-left corner of the bounding box. This value marks the vertical 
         starting point of the box.""",
-        example=0.0,
     )
     x1: float = Field(
         ...,
         description="""The x-coordinate of the bottom-right corner of the bounding box. This value marks the 
         horizontal endpoint of the box.""",
-        example=100.0,
     )
     y1: float = Field(
         ...,
         description="""The y-coordinate of the bottom-right corner of the bounding box. This value marks the vertical 
         endpoint of the box.""",
-        example=100.0,
     )
 
+    model_config = ConfigDict(json_schema_extra={"example": {"x0": 0.0, "y0": 0.0, "x1": 100.0, "y1": 100.0}})
+
     @field_validator("x0", "y0", "x1", "y1")
     @classmethod
     def bbox_corners_must_be_positive(cls, v: int) -> int:
@@ -177,22 +178,20 @@ class Coordinates(BaseModel):
     """
 
     east: float = Field(
-        ...,
-        description="""Easting coordinate. The value should be in the units of the specified projection system.""",
-        example=1.0,
+        ..., description="""Easting coordinate. The value should be in the units of the specified projection system."""
     )
     north: float = Field(
         ...,
         description="""Northing coordinate. The value should be in the units of the specified projection system.""",
-        example=2.0,
     )
     projection: str = Field(
         ...,
         description="""Projection system used to reference the coordinates. This defines the coordinate reference
         system, such as 'LV95' for Swiss coordinate systems.""",
-        example="LV95",
     )
 
+    model_config = ConfigDict(json_schema_extra={"example": {"east": 1.0, "north": 2.0, "projection": "LV95"}})
+
 
 class BoundingBoxesRequest(ABC, BaseModel):
     """Request schema for the `bounding_boxes` endpoint.
@@ -215,15 +214,16 @@ class BoundingBoxesRequest(ABC, BaseModel):
         ...,
         description="""Path to the input PDF document file that contains the data to be extracted. This should be
         a valid file path, and the file should be accessible to the API.""",
-        example=Path("document.pdf"),
     )
+
     page_number: int = Field(
         ...,
         description="""Page number within the document where the extraction is to be performed. This is a 1-based 
         index (e.g., 1 for the first page), applicable for multi-page files like PDFs.""",
-        example=1,
     )
 
+    model_config = ConfigDict(json_schema_extra={"example": {"filename": "1007.pdf", "page_number": 1}})
+
     @field_validator("filename", mode="before")
     @classmethod
     def validate_filename(cls, value: str) -> str:
@@ -238,23 +238,23 @@ def page_number_must_be_positive(cls, v: int) -> int:
             raise ValueError("Page number must be a positive integer")
         return v
 
-    class Config:
-        """Make it possible to define an example for the entire request model in the Swagger UI.
-
-        The schema_extra attribute inside the Config class allows you to define a complete
-        example for the entire request model.
-        """
-
-        json_schema_extra = {"example": {"filename": "10012.pdf", "page_number": 1}}
-
 
 class BoundingBoxesResponse(BaseModel):
     """Response schema for the `bounding_boxes` endpoint, representing the bounding boxes of words on the page."""
 
     bounding_boxes: list[BoundingBox] = Field(
-        ...,
-        description="""List of bounding boxes for all words that are found on the requested page.""",
-        example=[{"x0": 0.0, "y0": 0.0, "x1": 100.0, "y1": 100.0}, {"x0": 150.0, "y0": 20.0, "x1": 220.0, "y1": 40.0}],
+        ..., description="""List of bounding boxes for all words that are found on the requested page."""
+    )
+
+    model_config = ConfigDict(
+        json_schema_extra={
+            "example": {
+                "bounding_boxes": [
+                    {"x0": 0.0, "y0": 0.0, "x1": 100.0, "y1": 100.0},
+                    {"x0": 150.0, "y0": 20.0, "x1": 220.0, "y1": 40.0},
+                ]
+            }
+        }
     )
 
 
@@ -296,13 +296,11 @@ class ExtractDataRequest(ABC, BaseModel):
         ...,
         description="""Path to the input PDF document file that contains the data to be extracted. This should be
         a valid file path, and the file should be accessible to the API.""",
-        example=Path("document.pdf"),
     )
     page_number: int = Field(
         ...,
         description="""Page number within the document where the extraction is to be performed. This is a 1-based 
         index (e.g., 1 for the first page), applicable for multi-page files like PDFs.""",
-        example=1,
     )
     bbox: BoundingBox = Field(
         ...,
@@ -311,13 +309,22 @@ class ExtractDataRequest(ABC, BaseModel):
         right and y increases downward. This box should be provided in PNG coordinates, and any 
         transformations to PDF coordinates are managed internally.
         """,
-        example={"x0": 0.0, "y0": 0.0, "x1": 100.0, "y1": 100.0},
     )
     format: FormatTypes = Field(
         ...,
         description="""Specifies the desired format for extracted data, allowing for options like `coordinates` or 
         other defined `FormatTypes` values. This dictates the structure of the output returned by the API.""",
-        example=FormatTypes.COORDINATES.value,
+    )
+
+    model_config = ConfigDict(
+        json_schema_extra={
+            "example": {
+                "filename": "1007.pdf",
+                "page_number": 1,
+                "bbox": {"x0": 0.0, "y0": 0.0, "x1": 200.0, "y1": 200.0},
+                "format": FormatTypes.COORDINATES.value,  # Adjust as needed
+            }
+        }
     )
 
     @field_validator("filename", mode="before")
@@ -342,22 +349,6 @@ def format_must_be_valid(cls, v: FormatTypes) -> FormatTypes:
             raise ValueError(f"Invalid format type: {v}")
         return v
 
-    class Config:
-        """Make it possible to define an example for the entire request model in the Swagger UI.
-
-        The schema_extra attribute inside the Config class allows you to define a complete
-        example for the entire request model.
-        """
-
-        json_schema_extra = {
-            "example": {
-                "filename": "10012.pdf",
-                "page_number": 1,
-                "bbox": {"x0": 0.0, "y0": 0.0, "x1": 200.0, "y1": 200.0},
-                "format": "coordinates",  # Adjust this to match your actual FormatTypes
-            }
-        }
-
 
 class ExtractDataResponse(ABC, BaseModel):
     """Base response schema for the `extract_data` endpoint, representing the extracted data's bounding box.
@@ -370,7 +361,10 @@ class ExtractDataResponse(ABC, BaseModel):
         ...,
         description="""Bounding box coordinates that define the area within the document where data was extracted.
         The box is specified in PNG coordinates, with the origin at the top-left corner (0,0).""",
-        example={"x0": 0.0, "y0": 0.0, "x1": 100.0, "y1": 100.0},
+    )
+
+    model_config = ConfigDict(
+        json_schema_extra={"example": {"bbox": {"x0": 0.0, "y0": 0.0, "x1": 100.0, "y1": 100.0}}}
     )
 
     @property
@@ -389,7 +383,9 @@ class ExtractCoordinatesResponse(ExtractDataResponse):
         ...,
         description="""Geographical coordinates extracted from the document, including east and north values, 
         and projection type.""",
-        example={"east": 1.0, "north": 2.0, "projection": "LV95"},
+    )
+    model_config = ConfigDict(
+        json_schema_extra={"example": {"coordinates": {"east": 1.0, "north": 2.0, "projection": "LV95"}}}
     )
 
     @property
@@ -406,8 +402,8 @@ class ExtractTextResponse(ExtractDataResponse):
     text: str = Field(
         ...,
         description="""Text content extracted from the specified bounding box within the document.""",
-        example="text",
     )
+    model_config = ConfigDict(json_schema_extra={"example": {"text": "text"}})
 
     @property
     def response_type(self):
@@ -425,9 +421,10 @@ class ExtractNumberResponse(ExtractDataResponse):
         ...,
         description="""Numeric value extracted from the specified bounding box within the document, representing a
         measurement or quantitative data.""",
-        example=1.0,
     )
 
+    model_config = ConfigDict(json_schema_extra={"example": {"number": 1.0}})
+
     @property
     def response_type(self):
         return "number"
diff --git a/src/description_classification/__init__.py b/src/description_classification/__init__.py
new file mode 100644
index 00000000..893c1c42
--- /dev/null
+++ b/src/description_classification/__init__.py
@@ -0,0 +1,15 @@
+"""Contains package wide constants such as paths."""
+
+import os
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+load_dotenv()
+
+if os.getenv("BOREHOLES_DATA_PATH") is not None:
+    DATAPATH = Path(os.getenv("BOREHOLES_DATA_PATH"))
+else:
+    DATAPATH = Path(__file__).parent.parent.parent / "data"
+
+PROJECT_ROOT = Path(os.path.abspath(__file__)).parent.parent.parent
diff --git a/src/description_classification/classifiers/classifiers.py b/src/description_classification/classifiers/classifiers.py
new file mode 100644
index 00000000..cf7241b1
--- /dev/null
+++ b/src/description_classification/classifiers/classifiers.py
@@ -0,0 +1,92 @@
+"""Classifier module."""
+
+import re
+from typing import Protocol
+
+from description_classification.utils.data_loader import LayerInformations
+from description_classification.utils.uscs_classes import USCSClasses, map_most_similar_uscs
+from stratigraphy.util.util import read_params
+
+classification_params = read_params("classification_params.yml")
+
+
+class Classifier(Protocol):
+    """Classifier Protocol."""
+
+    def classify(self, layer_descriptions: list[LayerInformations]):
+        """Classifies the description of the LayerInformations objects.
+
+        This method will populate the prediction_uscs_class attribute of each object.
+
+        Args:
+            layer_descriptions (list[LayerInformations]): The LayerInformations object
+
+        """
+
+
+class DummyClassifier:
+    """Dummy classifier class.
+
+    Assigns the class USCSClasses.CL_ML to all descriptions
+    """
+
+    def classify(self, layer_descriptions: list[LayerInformations]):
+        """Classifies the description of the LayerInformations objects.
+
+        This method will populate the prediction_uscs_class attribute of each object.
+
+        Args:
+            layer_descriptions (list[LayerInformations]): The LayerInformations object
+
+        """
+        for layer in layer_descriptions:
+            layer.prediction_uscs_class = USCSClasses.CL_ML
+
+
+class BaselineClassifier:
+    """Baseline classifier class.
+
+    Performs a key-word matching to assign the classes.
+    """
+
+    def classify(self, layer_descriptions: list[LayerInformations]):
+        """Classifies the description of the LayerInformations objects.
+
+        This method will populate the prediction_uscs_class attribute of each object. The layer's soil description is
+        matched with the description of the USCS class. For example the class SW-SC has for description "sable bien
+        gradué avec argile (SW-SC)" in the classification_params.yml file, so it will try to identify the pattern
+        "sable bien gradué avec argile" in the layer's descriptions.
+
+        Args:
+            layer_descriptions (list[LayerInformations]): The LayerInformations object
+
+        """
+        uscs_patterns = classification_params["uscs_patterns"]
+
+        for layer in layer_descriptions:
+            patterns = uscs_patterns[layer.language]
+            description = layer.material_description.lower()
+            detected_class: USCSClasses | None = None
+
+            all_matches: dict[USCSClasses:str] = dict()
+
+            # Iterate over USCS classes and match with regex
+            for class_key, class_keywords in patterns.items():
+                uscs_class = map_most_similar_uscs(class_key)
+                # Create a regex pattern without the class name in parenthesis
+                regex_pattern = r"\b" + re.escape(re.sub(r"\(.*?\)", "", class_keywords).strip()) + r"\b"
+
+                # Search for an exact match of the full pattern
+                match = re.search(regex_pattern, description)
+                if match:
+                    matched_text = match.group(0)
+                    all_matches[uscs_class] = matched_text
+
+            longest_match: str | None = None
+            for matched_class, matched_text in all_matches.items():
+                if longest_match is None or len(matched_text.split()) > len(longest_match.split()):
+                    longest_match = matched_text
+                    detected_class = matched_class
+
+            # Assign the detected class or default to "keine Angabe" (kA)
+            layer.prediction_uscs_class = detected_class if detected_class else USCSClasses.kA
diff --git a/src/description_classification/evaluation/evaluate.py b/src/description_classification/evaluation/evaluate.py
new file mode 100644
index 00000000..12fab4a7
--- /dev/null
+++ b/src/description_classification/evaluation/evaluate.py
@@ -0,0 +1,243 @@
+"""Evaluation module."""
+
+import logging
+import os
+from dataclasses import dataclass
+
+from description_classification.utils.data_loader import LayerInformations
+from description_classification.utils.uscs_classes import USCSClasses
+from stratigraphy.evaluation.evaluation_dataclasses import Metrics
+from stratigraphy.util.util import read_params
+
+logger = logging.getLogger(__name__)
+
+mlflow_tracking = os.getenv("MLFLOW_TRACKING") == "True"  # Checks whether MLFlow tracking is enabled
+if mlflow_tracking:
+    import mlflow
+
+classification_params = read_params("classification_params.yml")
+
+
+@dataclass
+class AllClassificationMetrics:
+    """Stores classification metrics at both global and language-specific levels.
+
+    Attributes:
+        global_metrics (dict[USCSClasses, Metrics]): A dictionary containing the classification metrics
+            for each of the USCS classes at a global level.
+        language_metrics (dict[str, dict[USCSClasses, Metrics]]): A dictionary where each key represents
+            a supported language. Each value is another dictionary containing the classification metrics
+            for each of the USCS classes in that language.
+    """
+
+    global_metrics: dict[USCSClasses, Metrics]
+    language_metrics: dict[str, dict[USCSClasses, Metrics]]
+
+    @staticmethod
+    def compute_macro_average(metric_list: list[Metrics]) -> dict[str, float]:
+        """Computes the Macro Average of a list of metrics.
+
+        Each metric is first calculated per class and then averaged. This is useful when there is a large class
+        imbalance, ensuring that all classes are given equal importance regardless of their size.
+
+        Args:
+            metric_list (list[Metrics]): The list of per-class metrics.
+
+        Returns:
+            dict[str, float]: The dict with macro-averaged metrics, with keys macro_precision, macro_recall and
+                macro_f1.
+        """
+        if not metric_list:
+            return {"macro_precision": 0, "macro_recall": 0, "macro_f1": 0}
+
+        precisions = [metrics.precision for metrics in metric_list]
+        recalls = [metrics.recall for metrics in metric_list]
+        f1s = [metrics.f1 for metrics in metric_list]
+
+        return {
+            "macro_precision": round(sum(precisions) / len(precisions), 4),
+            "macro_recall": round(sum(recalls) / len(recalls), 4),
+            "macro_f1": round(sum(f1s) / len(f1s), 4),
+        }
+
+    @staticmethod
+    def compute_micro_average(metric_list: list[Metrics]) -> dict[str, float]:
+        """Computes the Micro Average of a list of metrics.
+
+        Unlike macro averaging, micro averaging aggregates true positives, false positives, and false negatives across
+        all classes before computing precision, recall, and F1-score.
+        This gives more weight to larger classes, making it suitable when class imbalance is present.
+
+        Args:
+            metric_list (list[Metrics]): The list of per-class metrics.
+
+        Returns:
+            dict[str, float]: The dict with micro-averaged metrics, with keys micro_precision, micro_recall,
+                and micro_f1.
+        """
+        if not metric_list:
+            return {"micro_precision": 0, "micro_recall": 0, "micro_f1": 0}
+
+        all_aggregated_metric = Metrics.micro_average(metric_list)
+
+        return {
+            "micro_precision": round(all_aggregated_metric.precision, 4),
+            "micro_recall": round(all_aggregated_metric.recall, 4),
+            "micro_f1": round(all_aggregated_metric.f1, 4),
+        }
+
+    @property
+    def global_macro_avg_dict(self) -> dict[str, float]:
+        """Dictionary containing the f1, recall and precision, macro averaged across all classes.
+
+        Returns:
+            dict[str, float]: The dictionary
+        """
+        return {f"global_{k}": v for k, v in self.compute_macro_average(self.global_metrics.values()).items()}
+
+    @property
+    def global_micro_avg_dict(self) -> dict[str, float]:
+        """Dictionary containing the f1, recall and precision, micro averaged across all classes.
+
+        Returns:
+            dict[str, float]: The dictionary
+        """
+        return {f"global_{k}": v for k, v in self.compute_micro_average(self.global_metrics.values()).items()}
+
+    @property
+    def per_language_macro_avg_metrics_dict(self) -> dict[str, float]:
+        """Dictionary containing f1, recall and precision for each language, macro averaged across all classes.
+
+        Returns:
+            dict[str, float]: The dictionary
+        """
+        return {
+            f"{language}_{k}": v
+            for language, metrics_dict in self.language_metrics.items()
+            for k, v in self.compute_macro_average(metrics_dict.values()).items()
+        }
+
+    @property
+    def per_language_micro_avg_metrics_dict(self) -> dict[str, float]:
+        """Dictionary containing f1, recall and precision for each language, micro averaged across all classes.
+
+        Returns:
+            dict[str, float]: The dictionary
+        """
+        return {
+            f"{language}_{k}": v
+            for language, metrics_dict in self.language_metrics.items()
+            for k, v in self.compute_micro_average(metrics_dict.values()).items()
+        }
+
+    @property
+    def per_class_global_metrics_dict(self) -> dict[str, float]:
+        """Dictionary containing the global f1, recall and precision, detailled for each uscs class.
+
+        Returns:
+            dict[str, float]: The dictionary
+        """
+        return {
+            k: v
+            for class_, metrics in self.global_metrics.items()
+            for k, v in metrics.to_json(f"global_{class_.name}").items()
+        }
+
+    @property
+    def per_class_per_language_metrics_dict(self) -> dict[str, float]:
+        """Dictionary containing the f1, recall and precision for each language, detailled for each uscs class.
+
+        Returns:
+            dict[str, float]: The dictionary
+        """
+        return {
+            k: v
+            for language, metrics_dict in self.language_metrics.items()
+            for class_, metrics in metrics_dict.items()
+            for k, v in metrics.to_json(f"{language}_{class_.name}").items()
+        }
+
+    def to_json(self) -> dict[str, float]:
+        """Returns the metrics as dict, the metrics are reduced by taking the macro average across all classes.
+
+        Returns:
+            dict[str, float]: the dictionary.
+        """
+        return {
+            **self.global_macro_avg_dict,
+            **self.per_language_macro_avg_metrics_dict,
+            **self.global_micro_avg_dict,
+            **self.per_language_micro_avg_metrics_dict,
+        }
+
+    def to_json_per_class(self) -> dict[str, float]:
+        """Returns the metrics as dict, detailing each classes.
+
+        Returns:
+            dict[str, float]: the dictionary.
+        """
+        return {
+            **self.per_class_global_metrics_dict,
+            **self.per_class_per_language_metrics_dict,
+        }
+
+
+def evaluate(layer_descriptions: list[LayerInformations]) -> AllClassificationMetrics:
+    """Evaluates the predictions of the LayerInformations objects against the ground truth.
+
+    Args:
+        layer_descriptions (list[LayerInformations]): the LayerInformations objects
+
+    Returns:
+        AllClassificationMetrics: the holder for the metrics
+    """
+    global_metrics: dict[USCSClasses, Metrics] = per_class_metrics(layer_descriptions)
+
+    supported_language: list[str] = classification_params["supported_language"]
+    language_metrics: dict[str, dict[USCSClasses, Metrics]] = {
+        language: per_class_metrics([layer for layer in layer_descriptions if layer.language == language])
+        for language in supported_language
+    }
+
+    all_classification_metrics = AllClassificationMetrics(global_metrics, language_metrics)
+
+    if mlflow_tracking:
+        log_metrics_to_mlflow(all_classification_metrics)
+        logger.info("Logging metrics to MLFlow")
+
+    return all_classification_metrics
+
+
+def per_class_metrics(layers: list[LayerInformations]) -> dict[USCSClasses, Metrics]:
+    """Compute per-class classification metrics.
+
+    Args:
+        layers (list[LayerInformations]): the layers to compute the metrics from.
+
+    Returns:
+        Dict[USCSClasses, Metrics]: A dictionary mapping each class to its TP, FP, FN.
+    """
+    all_classes = set([layer.prediction_uscs_class for layer in layers]) | set(
+        [layer.ground_truth_uscs_class for layer in layers]
+    )
+    metrics_per_class = {}
+
+    for cls in all_classes:
+        tp = sum(1 for layer in layers if layer.prediction_uscs_class == layer.ground_truth_uscs_class == cls)
+        fp = sum(1 for layer in layers if layer.prediction_uscs_class == cls and layer.ground_truth_uscs_class != cls)
+        fn = sum(1 for layer in layers if layer.prediction_uscs_class != cls and layer.ground_truth_uscs_class == cls)
+
+        metrics_per_class[cls] = Metrics(tp=tp, fp=fp, fn=fn)
+
+    return metrics_per_class
+
+
+def log_metrics_to_mlflow(all_classification_metrics: AllClassificationMetrics):
+    """Log metrics to MLFlow with error handling."""
+    # Log overall metrics
+    for name, value in all_classification_metrics.to_json().items():
+        mlflow.log_metric(name, value)
+
+    # Log per-class metrics
+    for name, value in all_classification_metrics.to_json_per_class().items():
+        mlflow.log_metric(name, value)
diff --git a/src/description_classification/main.py b/src/description_classification/main.py
new file mode 100644
index 00000000..ad33df15
--- /dev/null
+++ b/src/description_classification/main.py
@@ -0,0 +1,138 @@
+"""This module contains the main pipeline for the classification of the layer's soil descriptions."""
+
+import logging
+import os
+from pathlib import Path
+
+import click
+from dotenv import load_dotenv
+
+from description_classification import DATAPATH
+from description_classification.classifiers.classifiers import BaselineClassifier, Classifier
+from description_classification.evaluation.evaluate import evaluate
+from description_classification.utils.data_loader import LayerInformations, load_data
+from description_classification.utils.data_utils import (
+    get_data_class_count,
+    get_data_language_count,
+    write_predictions,
+)
+
+load_dotenv()
+
+mlflow_tracking = os.getenv("MLFLOW_TRACKING") == "True"  # Checks whether MLFlow tracking is enabled
+if mlflow_tracking:
+    import mlflow
+
+logging.basicConfig(format="%(asctime)s %(levelname)-8s %(message)s", level=logging.INFO, datefmt="%Y-%m-%d %H:%M:%S")
+logger = logging.getLogger(__name__)
+
+
+def setup_mlflow_tracking(
+    file_path: Path,
+    out_directory: Path,
+    file_subset_directory: Path,
+    experiment_name: str = "Layer descriptions classification",
+):
+    """Set up MLFlow tracking."""
+    if mlflow.active_run():
+        mlflow.end_run()  # Ensure the previous run is closed
+    mlflow.set_experiment(experiment_name)
+    mlflow.start_run()
+    mlflow.set_tag("json file_path", str(file_path))
+    mlflow.set_tag("out_directory", str(out_directory))
+    mlflow.set_tag("file_subset_directory", str(file_subset_directory))
+
+
+def log_ml_flow_infos(
+    file_path: Path, out_directory: Path, layer_descriptions: list[LayerInformations], classifier: Classifier
+):
+    """Logs informations to mlflow, such as the number of sample, laguage distribution, classifier type and data."""
+    # Log dataset statistics
+    mlflow.log_param("dataset_size", len(layer_descriptions))
+
+    # Log language distribution
+    for language, count in get_data_language_count(layer_descriptions).items():
+        mlflow.log_param(f"language_{language}_count", count)
+
+    # Log class distribution
+    for class_, count in get_data_class_count(layer_descriptions).items():
+        mlflow.log_param(f"class_{class_}_count", count)
+
+    # Log model name
+    mlflow.log_param("classifier_type", classifier.__class__.__name__)
+
+    # Log input data and output predictions
+    mlflow.log_artifact(str(file_path), "input_data")
+    mlflow.log_artifact(f"{out_directory}/uscs_class_predictions.json", "predictions_json")
+
+
+def common_options(f):
+    """Decorator to add common options to commands."""
+    f = click.option(
+        "-f",
+        "--file-path",
+        required=True,
+        type=click.Path(exists=True, path_type=Path),
+        help="Path to the json file.",
+    )(f)
+    f = click.option(
+        "-o",
+        "--out-directory",
+        type=click.Path(path_type=Path),
+        default=DATAPATH / "output_description_classification",
+        help="Path to the output directory.",
+    )(f)
+    f = click.option(
+        "-s",
+        "--file-subset-directory",
+        type=click.Path(path_type=Path),
+        default=None,
+        help="Path to the directory containing subset files (e.g. data/geoquat/train)."
+        " If not provided, the full JSON file is used.",
+    )(f)
+    return f
+
+
+@click.command()
+@common_options
+def click_pipeline(file_path: Path, out_directory: Path, file_subset_directory: Path):
+    """Run the description classification pipeline."""
+    main(file_path, out_directory, file_subset_directory)
+
+
+def main(file_path: Path, out_directory: Path, file_subset_directory: Path):
+    """Main pipeline to classify the layer's soil descriptions.
+
+    Args:
+        file_path (Path): Path to the ground truth json file.
+        out_directory (Path): Path to output directory
+        file_subset_directory (Path): Path to the directory containing the file whose names are used.
+    """
+    if mlflow_tracking:
+        setup_mlflow_tracking(file_path, out_directory, file_subset_directory)
+
+    logger.info(f"Loading data from {file_path}")
+    layer_descriptions = load_data(file_path, file_subset_directory)
+
+    classifier: Classifier = BaselineClassifier()
+    logger.info(f"Classifying layer description with {classifier.__class__.__name__}")
+    classifier.classify(layer_descriptions)
+
+    write_predictions(layer_descriptions, out_directory)
+
+    if mlflow_tracking:
+        log_ml_flow_infos(file_path, out_directory, layer_descriptions, classifier)
+
+    logger.info("Evaluating predictions")
+    classification_metrics = evaluate(layer_descriptions)
+    logger.info(f"classification metrics: {classification_metrics.to_json()}")
+    logger.debug(f"classification metrics per class: {classification_metrics.to_json_per_class()}")
+
+    if mlflow_tracking:
+        mlflow.end_run()
+
+
+if __name__ == "__main__":
+    # launch with: python -m src.description_classification.main -f data/geoquat_ground_truth.json
+    # or with: boreholes-classify-descriptions  -f data/geoquat_ground_truth.json
+    click_pipeline()
diff --git a/src/description_classification/utils/data_loader.py b/src/description_classification/utils/data_loader.py
new file mode 100644
index 00000000..2b6188a9
--- /dev/null
+++ b/src/description_classification/utils/data_loader.py
@@ -0,0 +1,81 @@
+"""Data loader module."""
+
+import json
+import logging
+from dataclasses import dataclass
+from os import listdir
+from os.path import isfile, join
+from pathlib import Path
+
+from description_classification.utils.language_detection import detect_language
+from description_classification.utils.uscs_classes import USCSClasses, map_most_similar_uscs
+from stratigraphy.util.util import read_params
+
+classification_params = read_params("classification_params.yml")
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class LayerInformations:
+    """Class for each layer in the ground truth json file."""
+
+    filename: str
+    borehole_index: int
+    layer_index: int
+    language: str
+    material_description: str
+    ground_truth_uscs_class: None | USCSClasses
+    prediction_uscs_class: None | USCSClasses  # dynamically set
+
+
+def load_data(json_path: Path, file_subset_directory: Path | None) -> list[LayerInformations]:
+    """Loads the data from the ground truth json file.
+
+    Args:
+        json_path (Path): the ground truth json file path
+        file_subset_directory (Path): Path to the directory containing the file whose names are used.
+
+    Returns:
+        list[LayerInformations]: the data formated as a list of LayerInformations objects
+    """
+    with open(json_path, encoding="utf-8") as f:
+        data = json.load(f)
+
+    if file_subset_directory is None:
+        logger.info(f"Using all filenames in: {json_path}")
+        filename_subset = None
+    else:
+        logger.info(f"Using files from subset directory: {file_subset_directory}")
+        filename_subset = {f for f in listdir(file_subset_directory) if isfile(join(file_subset_directory, f))}
+
+    layer_descriptions: list[LayerInformations] = []
+    for filename, boreholes in data.items():
+        if filename_subset is not None and filename not in filename_subset:
+            continue
+        all_text = " ".join([lay["material_description"] for bh in boreholes for lay in bh["layers"]])
+        language = detect_language(
+            all_text, classification_params["default_language"], classification_params["supported_language"]
+        )
+        for borehole in boreholes:
+            for layer_index, layer in enumerate(borehole["layers"]):
+                if not layer["uscs_1"]:
+                    logger.debug(
+                        f"Skippping layer: no ground truth for {filename}, borehole {borehole['borehole_index']}, "
+                        f"layer {layer_index} with description {layer['material_description']}."
+                    )
+                    continue
+                uscs_class = map_most_similar_uscs(layer["uscs_1"])
+                layer_descriptions.append(
+                    LayerInformations(
+                        filename,
+                        borehole["borehole_index"],
+                        layer_index,
+                        language,
+                        layer["material_description"],
+                        uscs_class,
+                        prediction_uscs_class=None,
+                    )
+                )
+
+    return layer_descriptions
diff --git a/src/description_classification/utils/data_utils.py b/src/description_classification/utils/data_utils.py
new file mode 100644
index 00000000..a6868e19
--- /dev/null
+++ b/src/description_classification/utils/data_utils.py
@@ -0,0 +1,75 @@
+"""Data utils module."""
+
+import json
+from collections import Counter
+from pathlib import Path
+
+from description_classification.utils.data_loader import LayerInformations
+
+
+def get_data_language_count(layer_descriptions: list[LayerInformations]) -> dict[str, int]:
+    """Returns the count of sample for each language.
+
+    Args:
+        layer_descriptions (list[LayerInformations]): All the layers.
+
+    Returns:
+        dict[str,int]: the count for each language.
+    """
+    language_counts = dict(Counter(layer.language for layer in layer_descriptions))
+    return language_counts
+
+
+def get_data_class_count(layer_descriptions: list[LayerInformations]) -> dict[str, int]:
+    """Returns the count of sample for each class.
+
+    Args:
+        layer_descriptions (list[LayerInformations]): All the layers.
+
+    Returns:
+        dict[str,int]: the count for each class.
+    """
+    class_counts = dict(Counter(layer.ground_truth_uscs_class.name for layer in layer_descriptions))
+    return class_counts
+
+
+def write_predictions(layers_with_predictions: list[LayerInformations], out_dir: Path):
+    """Writes the predictions and ground truth data to a JSON file.
+
+    Args:
+        layers_with_predictions (list[LayerInformations]): List of layers with predictions.
+        out_dir (Path): Path to the output directory.
+    """
+    out_dir.mkdir(parents=True, exist_ok=True)  # Ensure the output directory exists
+    output_file = out_dir / "uscs_class_predictions.json"
+
+    output_data = {}
+
+    for layer in layers_with_predictions:
+        if layer.filename not in output_data:
+            output_data[layer.filename] = []
+
+        # Find an existing borehole entry
+        borehole_entry = next(
+            (bh for bh in output_data[layer.filename] if bh["borehole_index"] == layer.borehole_index), None
+        )
+
+        # if the borehole does not exist, create it
+        if not borehole_entry:
+            borehole_entry = {"borehole_index": layer.borehole_index, "layers": []}
+            output_data[layer.filename].append(borehole_entry)
+
+        borehole_entry["layers"].append(
+            {
+                "layer_index": layer.layer_index,
+                "material_description": layer.material_description,
+                "language": layer.language,
+                "ground_truth_uscs_class": layer.ground_truth_uscs_class.name
+                if layer.ground_truth_uscs_class
+                else None,
+                "prediction_uscs_class": layer.prediction_uscs_class.name if layer.prediction_uscs_class else None,
+            }
+        )
+
+    with open(output_file, "w", encoding="utf-8") as f:
+        json.dump(output_data, f, ensure_ascii=False, indent=4)
diff --git a/src/description_classification/utils/language_detection.py b/src/description_classification/utils/language_detection.py
new file mode 100644
index 00000000..d85de262
--- /dev/null
+++ b/src/description_classification/utils/language_detection.py
@@ -0,0 +1,25 @@
+"""language module."""
+
+from langdetect import detect
+from langdetect.lang_detect_exception import LangDetectException
+
+
+def detect_language(text: str, default_language: str, supported_languages: list[str]) -> str:
+    """Detects the language of a given text.
+
+    Args:
+        text (str): The input text from which the language is detected.
+        default_language (str): The language to return if detection fails or the detected language is unsupported.
+        supported_languages (list[str]): A list of languages that are considered valid.
+
+    Returns:
+        str: The detected language if it is in the list of supported languages; otherwise, the default language.
+    """
+    try:
+        language = detect(text)
+    except LangDetectException:
+        language = default_language
+
+    if language not in supported_languages:
+        language = default_language
+    return language
diff --git a/src/description_classification/utils/uscs_classes.py b/src/description_classification/utils/uscs_classes.py
new file mode 100644
index 00000000..855b7ac0
--- /dev/null
+++ b/src/description_classification/utils/uscs_classes.py
@@ -0,0 +1,66 @@
+"""class module."""
+
+import logging
+from enum import Enum, auto
+
+logger = logging.getLogger(__name__)
+
+
+class USCSClasses(Enum):
+    """USCS (Unified Soil Classification System) classes."""
+
+    kunst = auto()
+    Bl = auto()
+    GP = auto()
+    CH = auto()
+    CM = auto()
+    CL = auto()
+    CL_ML = auto()
+    G = auto()
+    S = auto()
+    GW_GC = auto()
+    Pt = auto()
+    ML = auto()
+    GM = auto()
+    kA = auto()
+    FELS = auto()
+    SC = auto()
+    S_SM = auto()
+    SM = auto()
+    SP = auto()
+    SP_SC = auto()
+    SP_SM = auto()
+    SW = auto()
+    SW_SC = auto()
+    SW_SM = auto()
+    G_GC = auto()
+    G_GM = auto()
+    St = auto()
+    St_Bl = auto()
+    OH = auto()
+    OL = auto()
+    S_SC = auto()
+    SC_SM = auto()
+    GC = auto()
+    GC_GM = auto()
+    GP_GC = auto()
+    GP_GM = auto()
+    GW = auto()
+    GW_GM = auto()
+    MH = auto()
+
+
+def map_most_similar_uscs(uscs_str: str) -> USCSClasses:
+    """Maps the ground truth string to one of the USCSClasses.
+
+    Args:
+        uscs_str (str): the ground truth string
+
+    Returns:
+        USCSClasses: the matching class
+    """
+    for class_ in USCSClasses:
+        if uscs_str.replace("-", "_") == class_.name:
+            return class_
+    logger.warning(f"{uscs_str} does have a matching class, mapping it to {USCSClasses.kA.name} instead.")
+    return USCSClasses.kA  # keine Angabe = no indication
diff --git a/src/stratigraphy/depths_materials_column_pairs/bounding_boxes.py b/src/stratigraphy/depths_materials_column_pairs/bounding_boxes.py
index 834de75f..a94edeec 100644
--- a/src/stratigraphy/depths_materials_column_pairs/bounding_boxes.py
+++ b/src/stratigraphy/depths_materials_column_pairs/bounding_boxes.py
@@ -83,3 +83,26 @@ def from_material_description_rect_with_sidebar(
             material_description_bbox=BoundingBox(pair.material_description_rect),
             page=page_number,
         )
+
+    def get_outer_rect(self) -> fitz.Rect:
+        """Returns the extreme bounding rectangle.
+
+        Computes the smallest rectangle that encloses all bounding boxes in this PageBoundingBoxes object.
+
+        Returns:
+            fitz.Rect: The bounding rectangle.
+        """
+        all_bboxes = [self.material_description_bbox] + self.depth_column_entry_bboxes
+        if self.sidebar_bbox:
+            all_bboxes.append(self.sidebar_bbox)
+
+        if not all_bboxes:
+            raise ValueError("No bounding boxes available to determine extreme coordinates.")
+
+        # Compute extreme coordinates
+        min_x = min(bbox.rect.x0 for bbox in all_bboxes)
+        min_y = min(bbox.rect.y0 for bbox in all_bboxes)
+        max_x = max(bbox.rect.x1 for bbox in all_bboxes)
+        max_y = max(bbox.rect.y1 for bbox in all_bboxes)
+
+        return fitz.Rect(min_x, min_y, max_x, max_y)
diff --git a/src/stratigraphy/groundwater/groundwater_extraction.py b/src/stratigraphy/groundwater/groundwater_extraction.py
index 3469b344..cbb0be30 100644
--- a/src/stratigraphy/groundwater/groundwater_extraction.py
+++ b/src/stratigraphy/groundwater/groundwater_extraction.py
@@ -9,11 +9,8 @@
 import fitz
 import numpy as np
 from stratigraphy.data_extractor.data_extractor import DataExtractor, ExtractedFeature, FeatureOnPage
-from stratigraphy.data_extractor.utility import get_lines_near_rect
-from stratigraphy.depths_materials_column_pairs.bounding_boxes import BoundingBox
 from stratigraphy.groundwater.utility import extract_date, extract_depth, extract_elevation
 from stratigraphy.lines.line import TextLine
-from stratigraphy.metadata.elevation_extraction import Elevation
 
 logger = logging.getLogger(__name__)
 
@@ -105,6 +102,27 @@ def to_json(self) -> dict:
             "elevation": self.elevation,
         }
 
+    def set_elevation_infos(self, terrain_elevation: float):
+        """Sets the depth and elevation of the groundwater.
+
+        Args:
+            terrain_elevation (float): The elevation of the terrain at the top of the borehole.
+        """
+        if self.depth is None and self.elevation is None:  # can't do anything
+            return
+        if self.depth is None and self.elevation is not None:  # if was not already set
+            self.depth = round(terrain_elevation - self.elevation, 2)
+        if self.elevation is None and self.depth is not None:  # if was not already set
+            self.elevation = round(terrain_elevation - self.depth, 2)
+        # sanity checks here to correct mistake (e.g. wrong depth extracted... )
+        prec = 0.5
+        if round(terrain_elevation - self.elevation, 2) - self.depth > prec:  # account for approx, up to 0.5m
+            logger.warning(
+                f"Extracted groundwater height informations (depth = {self.depth}, elevation = {self.elevation}) "
+                f"do not match the constraint 'terrain_elevation - gw_elevation = gw_depth': {terrain_elevation} "
+                f"- {self.elevation} = {round(terrain_elevation - self.elevation, 2)} != {self.depth} ± {prec}"
+            )
+
 
 @dataclass
 class GroundwatersInBorehole:
@@ -132,12 +150,21 @@ def from_json(cls, json_object: list[dict]) -> "GroundwatersInBorehole":
         """
         return cls([FeatureOnPage.from_json(gw_data, Groundwater) for gw_data in json_object])
 
+    def set_elevation_infos(self, terrain_elevation: float):
+        """Sets the depth and elevation of all groundwater entries.
+
+        Args:
+            terrain_elevation (float): The elevation of the terrain at the top of the borehole.
+        """
+        for entry in self.groundwater_feature_list:
+            entry.feature.set_elevation_infos(terrain_elevation)
+
 
 @dataclass
 class GroundwaterInDocument:
     """Class for extracted groundwater information from a document."""
 
-    borehole_groundwaters: list[GroundwatersInBorehole]
+    groundwater_feature_list: list[FeatureOnPage[Groundwater]]
     filename: str
 
     def to_json(self) -> list[dict]:
@@ -146,7 +173,7 @@ def to_json(self) -> list[dict]:
         Returns:
             list[dict]: The object as a list of dictionaries.
         """
-        return [borehole_gw.to_json() for borehole_gw in self.borehole_groundwaters]
+        return [entry.to_json() for entry in self.groundwater_feature_list]
 
 
 class GroundwaterLevelExtractor(DataExtractor):
@@ -171,46 +198,6 @@ def __init__(self):
         if self.is_searching_groundwater_illustration:
             logger.info("Searching for groundwater information in illustrations.")
 
-    @classmethod
-    def near_material_description(
-        cls,
-        document: fitz.Document,
-        page_number: int,
-        lines: list[TextLine],
-        material_description_bbox: BoundingBox,
-        terrain_elevation: FeatureOnPage[Elevation] | None,
-    ) -> list[FeatureOnPage[Groundwater]]:
-        """Extracts groundwater information from a near material description bounding box on a page.
-
-        Args:
-            document (fitz.Document): The PDF document.
-            page_number (int): The page number (1-based) to process.
-            lines (list[TextLine]): The list of text lines to retrieve the groundwater from.
-            material_description_bbox (BoundingBox): The material description box from which
-            terrain_elevation (FeatureOnPage[Elevation] | None): The elevation of the terrain for the borehole (if
-                any is known).
-
-        Returns:
-            list[FeatureOnPage[Groundwater]]: The groundwater information near a material description bounding box.
-        """
-        groundwater_extractor = GroundwaterLevelExtractor()
-
-        lines_for_groundwater_key = get_lines_near_rect(
-            search_left_factor=4,
-            search_right_factor=4,
-            search_above_factor=2,
-            search_below_factor=3,
-            lines=lines,
-            rect=material_description_bbox.rect,
-        )
-
-        return groundwater_extractor.extract_groundwater(
-            page_number=page_number,
-            lines=lines_for_groundwater_key,
-            document=document,
-            terrain_elevation=terrain_elevation,
-        )
-
     def get_groundwater_near_key(self, lines: list[TextLine], page: int) -> list[FeatureOnPage[Groundwater]]:
         """Find groundwater information from text lines that are close to an explicit "groundwater" label.
 
@@ -346,7 +333,6 @@ def extract_groundwater(
         page_number: int,
         lines: list[TextLine],
         document: fitz.Document,
-        terrain_elevation: FeatureOnPage[Elevation] | None,
     ) -> list[FeatureOnPage[Groundwater]]:
         """Extracts the groundwater information from a borehole profile.
 
@@ -359,8 +345,6 @@ def extract_groundwater(
             page_number (int): The page number (1-based) of the PDF document.
             lines (list[TextLine]): The lines of text to extract the groundwater information from.
             document (fitz.Document): The document used to extract groundwater from illustration.
-            terrain_elevation (FeatureOnPage[Elevation] | None): The elevation of the terrain for the borehole (if any
-                is known)
 
         Returns:
             list[FeatureOnPage[Groundwater]]: the extracted coordinates (if any)
@@ -372,21 +356,12 @@ def extract_groundwater(
             )
 
             # Extract groundwater from illustration
-            found_groundwater, confidence_list = get_groundwater_from_illustration(
-                self, lines, page_number, document, terrain_elevation
-            )
+            found_groundwater, confidence_list = get_groundwater_from_illustration(self, lines, page_number, document)
 
             if found_groundwater:
                 logger.info("Confidence list: %s", confidence_list)
                 logger.info("Found groundwater from illustration on page %s: %s", page_number, found_groundwater)
 
-        if terrain_elevation:
-            for entry in found_groundwater:
-                if not entry.feature.depth and entry.feature.elevation:
-                    entry.feature.depth = round(terrain_elevation.feature.elevation - entry.feature.elevation, 2)
-                if not entry.feature.elevation and entry.feature.depth:
-                    entry.feature.elevation = round(terrain_elevation.feature.elevation - entry.feature.depth, 2)
-
         if found_groundwater:
             groundwater_output = ", ".join([str(entry.feature) for entry in found_groundwater])
             logger.info("Found groundwater information on page %s: %s", page_number, groundwater_output)
diff --git a/src/stratigraphy/groundwater/gw_illustration_template_matching.py b/src/stratigraphy/groundwater/gw_illustration_template_matching.py
index 207f8b3f..03586d42 100644
--- a/src/stratigraphy/groundwater/gw_illustration_template_matching.py
+++ b/src/stratigraphy/groundwater/gw_illustration_template_matching.py
@@ -16,7 +16,6 @@
 from stratigraphy.data_extractor.utility import get_lines_near_rect
 from stratigraphy.groundwater.groundwater_extraction import Groundwater, GroundwaterLevelExtractor
 from stratigraphy.lines.line import TextLine
-from stratigraphy.metadata.elevation_extraction import Elevation
 
 logger = logging.getLogger(__name__)
 
@@ -40,7 +39,6 @@ def get_groundwater_from_illustration(
     lines: list[TextLine],
     page_number: int,
     document: fitz.Document,
-    terrain_elevation: Elevation | None,
 ) -> tuple[list[FeatureOnPage[Groundwater]], list[float]]:
     """Extracts the groundwater information from an illustration.
 
@@ -49,7 +47,6 @@ def get_groundwater_from_illustration(
         lines (list[TextLine]): The lines of text to extract the groundwater information from.
         page_number (int): The page number (1-based) of the PDF document.
         document (fitz.Document): The document to extract groundwater from illustration from.
-        terrain_elevation (Elevation | None): The elevation of the terrain.
 
     Returns:
         list[FeatureOnPage[Groundwater]]: the extracted groundwater information
@@ -128,84 +125,21 @@ def distance_to_key_center(line_rect: fitz.Rect, illustration_rect: fitz.Rect) -
                 extracted_gw = groundwater_extractor.get_groundwater_info_from_lines(
                     groundwater_info_lines, page_number
                 )
-                if extracted_gw.groundwater.depth or extracted_gw.groundwater.elevation:
-                    # Fill in the depth and elevation if they are not already filled in based on the terrain
-                    if terrain_elevation:
-                        if not extracted_gw.groundwater.depth and extracted_gw.groundwater.elevation:
-                            extracted_gw.groundwater.depth = round(
-                                terrain_elevation.elevation - extracted_gw.groundwater.elevation, 2
-                            )
-                        if not extracted_gw.groundwater.elevation and extracted_gw.groundwater.depth:
-                            extracted_gw.groundwater.elevation = round(
-                                terrain_elevation.elevation - extracted_gw.groundwater.depth, 2
-                            )
-
-                        # Make a sanity check to see if elevation and depth make sense (i.e., they add up:
-                        # elevation + depth = terrain elevation)
-                        if extracted_gw.groundwater.elevation and extracted_gw.groundwater.depth:
-                            extract_terrain_elevation = round(
-                                extracted_gw.groundwater.elevation + extracted_gw.groundwater.depth, 2
-                            )
-                            if extract_terrain_elevation != terrain_elevation.elevation:
-                                # If the extracted elevation and depth do not match the terrain elevation, we try
-                                # to remove one of the items from the match and see if we can find a better match.
-                                logger.warning("The extracted elevation and depth do not match the terrain elevation.")
-                                logger.warning(
-                                    "Elevation: %s, Depth: %s, Terrain Elevation: %s",
-                                    extracted_gw.groundwater.elevation,
-                                    extracted_gw.groundwater.depth,
-                                    terrain_elevation.elevation,
-                                )
-
-                                # re-run the extraction and see if we can find a better match by removing one
-                                # item from the current match
-                                groundwater_info_lines_without_depth = [
-                                    line
-                                    for line in groundwater_info_lines
-                                    if str(extracted_gw.groundwater.depth) not in line.text
-                                ]
-                                groundwater_info_lines_without_elevation = [
-                                    line
-                                    for line in groundwater_info_lines
-                                    if str(extracted_gw.groundwater.elevation) not in line.text
-                                ]
-                                extracted_gw = groundwater_extractor.get_groundwater_info_from_lines(
-                                    groundwater_info_lines_without_depth, page_number
-                                )
-
-                                if not extracted_gw.groundwater.depth:
-                                    extracted_gw = groundwater_extractor.get_groundwater_info_from_lines(
-                                        groundwater_info_lines_without_elevation, page_number
-                                    )
-
-                                if extracted_gw.groundwater.elevation and extracted_gw.groundwater.depth:
-                                    extract_terrain_elevation = round(
-                                        extracted_gw.groundwater.elevation + extracted_gw.groundwater.depth, 2
-                                    )
-
-                                    if extract_terrain_elevation != terrain_elevation.elevation:
-                                        logger.warning(
-                                            "The extracted elevation and depth do not match the terrain elevation."
-                                        )
-                                        logger.warning(
-                                            "Elevation: %s, Depth: %s, Terrain Elevation: %s",
-                                            extracted_gw.groundwater.elevation,
-                                            extracted_gw.groundwater.depth,
-                                            terrain_elevation.elevation,
-                                        )
-                                        continue
-
+                if (
+                    (extracted_gw.groundwater.depth or extracted_gw.groundwater.elevation)
+                    and extracted_gw not in extracted_groundwater_list
+                    and extracted_gw.groundwater.date
+                ):
                     # Only if the groundwater information is not already in the list
-                    if extracted_gw not in extracted_groundwater_list and extracted_gw.groundwater.date:
-                        extracted_groundwater_list.append(extracted_gw)
-                        confidence_list.append(confidence)
-
-                        # Remove the extracted groundwater information from the lines to avoid double extraction
-                        for line in groundwater_info_lines:
-                            # if the rectangle of the line is in contact with the rectangle of the extracted
-                            # groundwater information, remove the line
-                            if line.rect.intersects(extracted_gw.rect):
-                                lines.remove(line)
+                    extracted_groundwater_list.append(extracted_gw)
+                    confidence_list.append(confidence)
+
+                    # Remove the extracted groundwater information from the lines to avoid double extraction
+                    for line in groundwater_info_lines:
+                        # if the rectangle of the line is in contact with the rectangle of the extracted
+                        # groundwater information, remove the line
+                        if line.rect.intersects(extracted_gw.rect):
+                            lines.remove(line)
 
             except ValueError as error:
                 logger.warning("ValueError: %s", error)
diff --git a/src/stratigraphy/groundwater/utility.py b/src/stratigraphy/groundwater/utility.py
index 69b43a2e..58aa87e5 100644
--- a/src/stratigraphy/groundwater/utility.py
+++ b/src/stratigraphy/groundwater/utility.py
@@ -7,14 +7,26 @@
 
 def extract_date(text: str) -> tuple[date | None, str | None]:
     """Extract the date from a string in the format dd.mm.yyyy or dd.mm.yy."""
-    date_match = regex.search(r"(\d{1,2}\.\d{1,2}\.\d{2,4})", text)
+    date_match = regex.search(r"(\d{1,2})\s*\.\s*(\d{1,2})\s*\.\s*(\d{2,4})", text)
 
     if not date_match:
         return None, None
+    original_date_str = date_match.group(0)
+    cleaned_date_str = f"{date_match.group(1)}.{date_match.group(2)}.{date_match.group(3)}"
+    date_format = "%d.%m.%y" if len(date_match.group(3)) == 2 else "%d.%m.%Y"
+    # Validate date before parsing
+    if not is_valid_date(cleaned_date_str, date_format):
+        return None, None
+    return datetime.strptime(cleaned_date_str, date_format).date(), original_date_str
+
 
-    date_str = date_match.group(1)
-    date_format = "%d.%m.%y" if len(date_str.split(".")[2]) == 2 else "%d.%m.%Y"
-    return datetime.strptime(date_str, date_format).date(), date_str
+def is_valid_date(date_str: str, date_format: str) -> bool:
+    """Check if a date string is valid for a given format."""
+    try:
+        datetime.strptime(date_str, date_format)  # Try parsing the date
+        return True
+    except ValueError:
+        return False
 
 
 def extract_depth(text: str, max_depth: int) -> float | None:
diff --git a/src/stratigraphy/main.py b/src/stratigraphy/main.py
index 82b97f41..5af8b973 100644
--- a/src/stratigraphy/main.py
+++ b/src/stratigraphy/main.py
@@ -3,7 +3,6 @@
 import json
 import logging
 import os
-from collections import defaultdict
 from pathlib import Path
 
 import click
@@ -19,7 +18,6 @@
 from stratigraphy.groundwater.groundwater_extraction import (
     GroundwaterInDocument,
     GroundwaterLevelExtractor,
-    GroundwatersInBorehole,
 )
 from stratigraphy.layer.duplicate_detection import remove_duplicate_layers
 from stratigraphy.layer.layer import LayersInDocument
@@ -94,6 +92,13 @@ def common_options(f):
         default=False,
         help="Whether to draw lines on pdf pages. Defaults to False.",
     )(f)
+    f = click.option(
+        "-c",
+        "--csv",
+        is_flag=True,
+        default=False,
+        help="Whether to generate CSV output. Defaults to False.",
+    )(f)
     return f
 
 
@@ -110,6 +115,7 @@ def click_pipeline(
     metadata_path: Path,
     skip_draw_predictions: bool = False,
     draw_lines: bool = False,
+    csv: bool = False,
     part: str = "all",
 ):
     """Run the boreholes data extraction pipeline."""
@@ -121,6 +127,7 @@ def click_pipeline(
         metadata_path=metadata_path,
         skip_draw_predictions=skip_draw_predictions,
         draw_lines=draw_lines,
+        csv=csv,
         part=part,
     )
 
@@ -186,6 +193,7 @@ def start_pipeline(
     metadata_path: Path,
     skip_draw_predictions: bool = False,
     draw_lines: bool = False,
+    csv: bool = False,
     part: str = "all",
 ):
     """Run the boreholes data extraction pipeline.
@@ -205,6 +213,7 @@ def start_pipeline(
         skip_draw_predictions (bool, optional): Whether to skip drawing predictions on pdf pages. Defaults to False.
         draw_lines (bool, optional): Whether to draw lines on pdf pages. Defaults to False.
         metadata_path (Path): The path to the metadata file.
+        csv (bool): Whether to generate a CSV output. Defaults to False.
         part (str, optional): The part of the pipeline to run. Defaults to "all".
     """  # noqa: D301
     if mlflow_tracking:
@@ -220,6 +229,10 @@ def start_pipeline(
         draw_directory = out_directory / "draw"
         draw_directory.mkdir(parents=True, exist_ok=True)
 
+    if csv:
+        csv_dir = out_directory / "csv"
+        csv_dir.mkdir(parents=True, exist_ok=True)
+
     # if a file is specified instead of an input directory, copy the file to a temporary directory and work with that.
     if input_directory.is_file():
         root = input_directory.parent
@@ -244,10 +257,9 @@ def start_pipeline(
             file_metadata = FileMetadata.from_document(doc)
             metadata = MetadataInDocument.from_document(doc)
 
-            # Save the predictions to the overall predictions object
-            # Initialize common variables
+            # Save the predictions to the overall predictions object, initialize common variables
             layers_with_bb_in_document = LayersInDocument([], filename)
-            aggregated_groundwater_entries = defaultdict(list)
+            all_groundwater_entries = GroundwaterInDocument([], filename)
 
             if part != "all":
                 continue
@@ -264,38 +276,12 @@ def start_pipeline(
                 processed_page_results = LayersInDocument(extracted_boreholes, filename)
 
                 # Extract the groundwater levels
-                for borehole_index, extracted_borehole in enumerate(processed_page_results.boreholes_layers_with_bb):
-                    # Each extracted_borehole contains only one BoundingBoxes element in its list,
-                    # so we extract the first element (index 0).
-                    # The reason for using a list is that later in the code, a single borehole may span multiple
-                    # pages, requiring multiple BoundingBoxes, one for each page.
-                    material_description_bbox = extracted_borehole.bounding_boxes[0].material_description_bbox
-
-                    # TODO: first match elevation with boreholes more intelligently, then pass the correct value to
-                    # the groundwater extraction logic
-                    terrain_elevation = None
-                    if metadata.elevations:
-                        if len(metadata.elevations) > borehole_index:
-                            terrain_elevation = metadata.elevations[borehole_index]
-                        else:
-                            terrain_elevation = metadata.elevations[0]
-
-                    groundwater_entries_near_bbox = GroundwaterLevelExtractor.near_material_description(
-                        document=doc,
-                        page_number=page_number,
-                        lines=text_lines,
-                        material_description_bbox=material_description_bbox,
-                        terrain_elevation=terrain_elevation,
-                    )
-                    # avoid duplicate entries
-                    seen_entry = [
-                        seen for borehole_gw in aggregated_groundwater_entries.values() for seen in borehole_gw
-                    ]
-                    for groundwater_entry in groundwater_entries_near_bbox:
-                        if groundwater_entry not in seen_entry:
-                            aggregated_groundwater_entries[borehole_index].append(groundwater_entry)
-
-                # TODO: Add remove duplicates here!
+                groundwater_extractor = GroundwaterLevelExtractor()
+                groundwater_entries = groundwater_extractor.extract_groundwater(
+                    page_number=page_number, lines=text_lines, document=doc
+                )
+                all_groundwater_entries.groundwater_feature_list.extend(groundwater_entries)
+
                 if page_index > 0:
                     layer_with_bb_predictions = remove_duplicate_layers(
                         previous_page=doc[page_index - 1],
@@ -316,26 +302,34 @@ def start_pipeline(
                         img = plot_lines(page, geometric_lines, scale_factor=line_detection_params["pdf_scale_factor"])
                         mlflow.log_image(img, f"pages/{filename}_page_{page.number + 1}_lines.png")
 
-            # Create a document-level groundwater entry
-            groundwater_entries = GroundwaterInDocument(
-                filename=filename,
-                borehole_groundwaters=[
-                    GroundwatersInBorehole(value) for _, value in sorted(aggregated_groundwater_entries.items())
-                ],
-            )
-
             # create list of BoreholePrediction objects with all the separate lists
             borehole_predictions_list: list[BoreholePredictions] = BoreholeListBuilder(
                 layers_with_bb_in_document=layers_with_bb_in_document,
                 file_name=filename,
-                groundwater_in_doc=groundwater_entries,
+                groundwater_in_doc=all_groundwater_entries,
                 elevations_list=metadata.elevations,
                 coordinates_list=metadata.coordinates,
             ).build()
+            # now that the matching is done, the depths of groundwater can be set
+            for borehole in borehole_predictions_list:
+                borehole.set_groundwater_elevation_infos()
 
             # Add file predictions
             predictions.add_file_predictions(FilePredictions(borehole_predictions_list, file_metadata, filename))
 
+            # Add layers to a csv file
+            if csv:
+                base_path = csv_dir / Path(filename).stem
+
+                for index, borehole in enumerate(borehole_predictions_list):
+                    csv_path = f"{base_path}_{index}.csv" if len(borehole_predictions_list) > 1 else f"{base_path}.csv"
+                    logger.info("Writing CSV predictions to %s", csv_path)
+                    with open(csv_path, "w", encoding="utf8", newline="") as file:
+                        file.write(borehole.to_csv())
+
+                    if mlflow_tracking:
+                        mlflow.log_artifact(csv_path, "csv")
+
     logger.info("Metadata written to %s", metadata_path)
     with open(metadata_path, "w", encoding="utf8") as file:
         json.dump(predictions.get_metadata_as_dict(), file, ensure_ascii=False)
diff --git a/src/stratigraphy/metadata/elevation_extraction.py b/src/stratigraphy/metadata/elevation_extraction.py
index fc56b842..ea47efac 100644
--- a/src/stratigraphy/metadata/elevation_extraction.py
+++ b/src/stratigraphy/metadata/elevation_extraction.py
@@ -108,7 +108,7 @@ def get_elevation_near_key(self, lines: list[TextLine], page: int) -> list[Featu
 
             try:
                 extracted_elevation = self.get_elevation_from_lines(elevation_lines, page)
-                if extracted_elevation.feature.elevation:
+                if extracted_elevation.feature.elevation and extracted_elevation not in extracted_elevation_list:
                     extracted_elevation_list.append(extracted_elevation)
             except ValueError as error:
                 logger.warning("ValueError: %s", error)
@@ -129,10 +129,6 @@ def select_best_elevation_information(
         Returns:
             list[FeatureOnPage[Elevation]]: The best extracted elevation information.
         """
-        # first remove the duplicate. Even if 2 boreholes have the same elevation, it will be handled later. Keeping
-        # duplicates here could only harm us.
-        extracted_elevation_list = list({obj.feature.elevation: obj for obj in extracted_elevation_list}.values())
-
         # Sort the extracted elevation information by elevation with the highest elevation first
         extracted_elevation_list.sort(key=lambda x: x.feature.elevation, reverse=True)
 
diff --git a/src/stratigraphy/util/borehole_predictions.py b/src/stratigraphy/util/borehole_predictions.py
index 881b3827..ffe063ce 100644
--- a/src/stratigraphy/util/borehole_predictions.py
+++ b/src/stratigraphy/util/borehole_predictions.py
@@ -1,6 +1,8 @@
 """Classes for the predictions per borehole, and for associating them with ground truth data."""
 
+import csv
 import dataclasses
+import io
 
 from stratigraphy.depths_materials_column_pairs.bounding_boxes import PageBoundingBoxes
 from stratigraphy.groundwater.groundwater_extraction import GroundwatersInBorehole
@@ -33,6 +35,35 @@ def to_json(self) -> dict:
             "groundwater": self.groundwater_in_borehole.to_json() if self.groundwater_in_borehole is not None else [],
         }
 
+    def to_csv(self) -> str:
+        """Converts borehole layer data to CSV format.
+
+        This method generates a CSV string containing layer data with
+        layer index, start depth, end depth and material description.
+
+        Returns:
+            str: CSV string representation of borehole predictions.
+        """
+        output = io.StringIO()
+        writer = csv.writer(output)
+        writer.writerow(["layer_index", "from_depth", "to_depth", "material_description"])
+
+        for layer_index, layer in enumerate(self.layers_in_borehole.layers):
+            start_depth = layer.depths.start.value if layer.depths and layer.depths.start else None
+            end_depth = layer.depths.end.value if layer.depths and layer.depths.end else None
+            material_description = layer.material_description.feature.text if layer.material_description else None
+
+            writer.writerow(
+                [
+                    layer_index,
+                    start_depth,
+                    end_depth,
+                    material_description,
+                ]
+            )
+
+        return output.getvalue()
+
     @classmethod
     def from_json(cls, json_object, file_name) -> "BoreholePredictions":
         """Extract a BoreholePrediction object from a json dictionary.
@@ -53,6 +84,13 @@ def from_json(cls, json_object, file_name) -> "BoreholePredictions":
             [PageBoundingBoxes.from_json(bbox_json) for bbox_json in json_object["bounding_boxes"]],
         )
 
+    def set_groundwater_elevation_infos(self):
+        """Sets the depth and elevation of the groundwater entries of this borehole."""
+        if not self.metadata.elevation:
+            return
+        borehole_terrain_elevation = self.metadata.elevation.feature.elevation
+        self.groundwater_in_borehole.set_elevation_infos(borehole_terrain_elevation)
+
 
 @dataclasses.dataclass
 class BoreholePredictionsWithGroundTruth:
diff --git a/src/stratigraphy/util/predictions.py b/src/stratigraphy/util/predictions.py
index a02c54a2..e8cc1069 100644
--- a/src/stratigraphy/util/predictions.py
+++ b/src/stratigraphy/util/predictions.py
@@ -2,11 +2,16 @@
 
 import dataclasses
 import logging
+from collections import defaultdict
 from copy import deepcopy
 from typing import TypeVar
 
+import numpy as np
+from scipy.optimize import linear_sum_assignment
+
 from stratigraphy.benchmark.metrics import OverallMetricsCatalog
 from stratigraphy.data_extractor.data_extractor import FeatureOnPage
+from stratigraphy.depths_materials_column_pairs.bounding_boxes import PageBoundingBoxes
 from stratigraphy.evaluation.evaluation_dataclasses import OverallBoreholeMetadataMetrics
 from stratigraphy.evaluation.groundwater_evaluator import GroundwaterEvaluator
 from stratigraphy.evaluation.layer_evaluator import LayerEvaluator
@@ -52,8 +57,8 @@ def __init__(
         """Initializes the BoreholeListBuilder with extracted borehole-related data.
 
         Args:
-            layers_with_bb_in_document (LayersInDocument): Object containing borehole layers extracted from the
-                document, along with its corespoding bounding boxes.
+            layers_with_bb_in_document (LayersInDocument): Object containing a list of extracted boreholes, each
+            holding a list of layers and a list of bounding boxes.
             file_name (str): The name of the processed document.
             groundwater_in_doc (GroundwaterInDocument): Contains detected groundwater entries for boreholes.
 
@@ -68,41 +73,44 @@ def __init__(
         self._coordinates_list = coordinates_list
 
     def build(self) -> list[BoreholePredictions]:
-        """Creates a list of BoreholePredictions after ensuring all lists have the same length."""
-        self._extend_length_to_match_boreholes_num_pred()
+        """Creates a list of BoreholePredictions after, mapping all elements to eachother."""
+        if not self._layers_with_bb_in_document.boreholes_layers_with_bb:
+            return []  # no borehole identified
+        self._extend_lenght_metadata_elements()
+
+        # for each element, perform the perfect matching with the borehole layers, and retrieve the matching dict
+        borehole_to_elevation_map = self._one_to_one_match_element_to_borehole(self._elevations_list)
+        borehole_to_coordinate_map = self._one_to_one_match_element_to_borehole(self._coordinates_list)
+        borehole_to_list_groundwater_map = self._many_to_one_match_element_to_borehole(
+            self._groundwater_in_doc.groundwater_feature_list
+        )
 
         return [
             BoreholePredictions(
                 borehole_index,
                 LayersInBorehole(layers_in_borehole_with_bb.predictions),
                 self._file_name,
-                BoreholeMetadata(elevation, coordinate),
-                groundwater,
+                BoreholeMetadata(
+                    self._elevations_list[borehole_to_elevation_map[borehole_index]],
+                    self._coordinates_list[borehole_to_coordinate_map[borehole_index]],
+                ),
+                GroundwatersInBorehole(
+                    [
+                        self._groundwater_in_doc.groundwater_feature_list[idx]
+                        for idx in borehole_to_list_groundwater_map[borehole_index]
+                    ]
+                ),
                 layers_in_borehole_with_bb.bounding_boxes,
             )
-            for borehole_index, (
-                layers_in_borehole_with_bb,
-                groundwater,
-                elevation,
-                coordinate,
-            ) in enumerate(
-                zip(
-                    self._layers_with_bb_in_document.boreholes_layers_with_bb,
-                    self._groundwater_in_doc.borehole_groundwaters,
-                    self._elevations_list,
-                    self._coordinates_list,
-                    strict=True,
-                )
+            for borehole_index, layers_in_borehole_with_bb in enumerate(
+                self._layers_with_bb_in_document.boreholes_layers_with_bb
             )
         ]
 
-    def _extend_length_to_match_boreholes_num_pred(self) -> None:
-        """Ensures that all lists have the same length by duplicating elements if necessary."""
+    def _extend_lenght_metadata_elements(self) -> None:
+        """Ensures that all lists have at least the same length by duplicating elements if necessary."""
         num_boreholes = len(self._layers_with_bb_in_document.boreholes_layers_with_bb)
 
-        self._groundwater_in_doc.borehole_groundwaters = self._extend_list(
-            self._groundwater_in_doc.borehole_groundwaters, GroundwatersInBorehole([]), num_boreholes
-        )
         self._elevations_list = self._extend_list(self._elevations_list, None, num_boreholes)
         self._coordinates_list = self._extend_list(self._coordinates_list, None, num_boreholes)
 
@@ -120,7 +128,97 @@ def create_new_elem():
         while len(lst) < target_length:
             lst.append(create_new_elem())  # Append copies to match the required length
 
-        return lst[:target_length]
+        return lst
+
+    def _many_to_one_match_element_to_borehole(self, element_list: list[FeatureOnPage]) -> dict[int, list[int]]:
+        """Matches extracted elements to boreholes.
+
+        This is done by assigning the clossest borehole to each element.
+
+        Args:
+            element_list (list[FeatureOnPage]): list of element to match
+
+        Returns:
+            dict[int, list[int]]: the dictionary containing the best mapping borehole_index -> all element_indexes
+        """
+        num_boreholes = len(self._layers_with_bb_in_document.boreholes_layers_with_bb)
+        # solve trivial case
+        if num_boreholes == 1:
+            return {0: [idx for idx in range(len(element_list))]}
+        # solve case where the list is empty
+        if not element_list:
+            return {idx: [] for idx in range(num_boreholes)}
+
+        # Get a list of references of all borehole bounding boxes
+        borehole_bounding_boxes = [
+            bh.bounding_boxes for bh in self._layers_with_bb_in_document.boreholes_layers_with_bb
+        ]
+
+        borehole_index_to_matched_elem_index = defaultdict(list)
+        for i, feat in enumerate(element_list):
+            best_bbox_idx = min(
+                range(len(borehole_bounding_boxes)),
+                key=lambda j: self._compute_distance(feat, borehole_bounding_boxes[j]),
+            )
+            borehole_index_to_matched_elem_index[best_bbox_idx].append(i)
+
+        return borehole_index_to_matched_elem_index
+
+    def _one_to_one_match_element_to_borehole(
+        self,
+        element_list: list[FeatureOnPage | None],
+    ) -> dict[int, int]:
+        """Matches extracted elements to boreholes.
+
+        This is done by minimizing the total sum of the distances from the elements to the corresponding layers.
+
+        Args:
+            element_list (list[FeatureOnPage  |  None ]): list of element to match
+
+        Returns:
+            dict[int, int]: the dictionary containing the best mapping borehole_index -> element_index
+        """
+        # solve trivial case and case where the elements are None
+        if len(element_list) == 1 or not element_list[0]:
+            return {idx: idx for idx in range(len(element_list))}
+
+        # Get a list of references of all borehole bounding boxes
+        borehole_bounding_boxes = [
+            bh.bounding_boxes for bh in self._layers_with_bb_in_document.boreholes_layers_with_bb
+        ]
+
+        cost_matrix = np.zeros((len(element_list), len(borehole_bounding_boxes)))
+
+        # Fill the cost matrix with distances between each element and each borehole
+        for i, feat in enumerate(element_list):
+            for j, bboxes in enumerate(borehole_bounding_boxes):
+                dist = self._compute_distance(feat, bboxes)  # Compute the distance
+                cost_matrix[i, j] = dist
+
+        # Use the Hungarian algorithm (Kuhn-Munkres) to solve the assignment problem.
+        # It finds the optimal one-to-one matching between elements and borehole layers such that the total
+        # distance (cost) between matched pairs is minimized.
+        # In simpler terms: out of all possible ways to pair elements with boreholes, this picks the combination that
+        # results in the shortest total distance between them.
+        elem_indexes, layer_indexes = linear_sum_assignment(cost_matrix)
+
+        # Store the index of the matched elements in a dictionary
+        borehole_index_to_matched_elem_index = {}
+        for elem_idx, layer_idx in zip(elem_indexes, layer_indexes, strict=True):
+            borehole_index_to_matched_elem_index[layer_idx] = elem_idx
+
+        return borehole_index_to_matched_elem_index
+
+    def _compute_distance(self, feat: FeatureOnPage, bounding_boxes: list[PageBoundingBoxes]) -> float:
+        """Computes the distance between a FeatureOnPage objects and the bounding boxes of one borehole."""
+        bbox = next((bbox for bbox in bounding_boxes if bbox.page == feat.page), None)
+        if bbox is None:
+            # the current boreholes layers don't appear on the page where the element is
+            return float("inf")
+        outer_rect = bbox.get_outer_rect()
+        element_center = (feat.rect.top_left + feat.rect.bottom_right) / 2
+        dist = element_center.distance_to(outer_rect)
+        return dist
 
 
 @dataclasses.dataclass


Test patch:
diff --git a/tests/test_groundwater.py b/tests/test_groundwater.py
index ed4c4430..66eacc92 100644
--- a/tests/test_groundwater.py
+++ b/tests/test_groundwater.py
@@ -1,5 +1,7 @@
 """Tests for the groundwater module."""
 
+from datetime import date
+
 import pytest
 from stratigraphy.benchmark.ground_truth import GroundTruth
 from stratigraphy.data_extractor.data_extractor import FeatureOnPage
@@ -10,9 +12,27 @@
     OverallGroundwaterMetrics,
 )
 from stratigraphy.groundwater.groundwater_extraction import Groundwater, GroundwatersInBorehole
+from stratigraphy.groundwater.utility import extract_date
 from stratigraphy.util.borehole_predictions import BoreholeGroundwaterWithGroundTruth, FileGroundwaterWithGroundTruth
 
 
+@pytest.fixture
+def date_test_cases():
+    """Provides test cases for extract_date function."""
+    return [
+        # Valid full-year dates
+        ("The date is 12.05.1998", date(1998, 5, 12), "12.05.1998"),
+        # Valid short-year dates
+        ("Event on 07.04.99", date(1999, 4, 7), "07.04.99"),
+        # Extra spaces
+        ("  10 . 03 . 1985 ", date(1985, 3, 10), "10 . 03 . 1985"),
+        # Invalid dates
+        ("Invalid format: 30.02.2020", None, None),
+        # No date in text
+        ("No date here!", None, None),
+    ]
+
+
 @pytest.fixture
 def sample_metrics():
     """Sample metrics for testing."""
@@ -55,6 +75,12 @@ def groundwater_at_3m22() -> dict:
     )
 
 
+def test_extract_date(date_test_cases):
+    """Test extract_date function with various inputs."""
+    for text, expected_date, expected_str in date_test_cases:
+        assert extract_date(text) == (expected_date, expected_str)
+
+
 def test_add_groundwater_metrics(sample_metrics):
     """Test adding GroundwaterMetrics to OverallGroundwaterMetrics."""
     overall_metrics = OverallGroundwaterMetrics()