Repository: swisstopo/swissgeol-boreholes-dataextraction

Issue:
Restructure ground truth and predictions to allow for multiple boreholes per input PDF
[Migrated from LGVSIUM-98.]

How many input PDFs do we have where this makes a difference at all (i.e. more than one borehole in a single PDF)?

Known examples in Zurich dataset: 267125358-bp.pdf, 268124232-bp.pdf, 268125295-bp.pdf

How about metadata? Each borehole in a single PDF might have their own metadata. So ideally, we need to allow for this in the output format as well. But we also need to consider the case where the PDF might list certain metadata only once, and this value applies to all boreholes in the PDF (e.g. same date and/or coordinates for all boreholes).

Current behaviour:

- Different stratigraphies (depth column + material descripions) are correctly recognized, but their data is simple concatenated into a single list of layers when generating the output.
- Groundwater data and metadata is directly associated with the file in the output, not with a particular stratigraphy.

Hints from issue comments:
@stijnvermeeren-swisstopo: A full list of relevant PDFs in the Zurich dataset according to @TicaGit :
- 695265009-bp.pdf
- 267125358-bp.pdf
- 269126143-bp.pdf
- 675245009-bp.pdf
- 681249142-bp.pdf
- 268124232-bp.pdf
- 268123018-bp.pdf
- 268124112-bp.pdf
- 268125295-bp.pdf 

Solution patch:
diff --git a/README.md b/README.md
index 1a2fa3c1..004b7b82 100644
--- a/README.md
+++ b/README.md
@@ -106,6 +106,8 @@ Alternatively you can replace the `pip install -e '.[all]'` command with `pip in
 
 Adding pip packages can be done by editing the `pyproject.toml` of the project and adding the required package.
 
+If you are using a version of Python newer than 3.12 (e.g. 3.13), you may need to use the command ` python3.12 -m venv env `instead.
+
 ## Run data extraction
 To execute the data extraction pipeline, follow these steps:
 
@@ -119,7 +121,31 @@ source env/bin/activate
 
 2. **Download the borehole profiles, optional**
 
-Use `boreholes-download-profiles` to download the files to be processed from an AWS S3 storage. In order to do so, you need to authenticate with aws first. We recommend to use the aws CLI for that purpose. This step is optional, you can continue with step 3 on your own set of borehole profiles.
+Use `boreholes-download-profiles` to download the files to be processed from an AWS S3 storage. In order to do so, you need to authenticate with aws first. We recommend using the aws CLI for that purpose, or storing your credentials in the ~/.aws configuration files This step is optional, you can continue with step 3 on your own set of borehole profiles.
+
+Alternativelly, you can download the data directly using the AWS CLI:
+```bash 
+brew install awscli
+aws s3 sync s3://stijnvermeeren-boreholes-data ./data
+```
+
+If you choose to use the ~/.aws files, then they should look like this:
+
+**~/.aws/config**
+
+  ```
+  [default]
+  region=eu-central-1
+  output=json
+  ```  
+
+ **~/.aws/credentials**
+
+  ```
+  [default]
+  aws_access_key_id=YOUR_ACCESS_KEY
+  aws_secret_access_key=YOUR_SECRET_KEY
+  ```  
 
 3. **Run the extraction script**
 
@@ -243,6 +269,8 @@ docker build -t borehole-api . -f Dockerfile
 docker build --platform linux/amd64 -t borehole-api:test .
 ```
 
+If docker is not setup yet, you might need to first use `docker login -u <username>` to login to your docker account.
+
 This command will build the Docker image with the tag `borehole-api`.
 
 3. **Verify the Docker image**
diff --git a/config/matching_params.yml b/config/matching_params.yml
index dab1888a..8514e4e9 100644
--- a/config/matching_params.yml
+++ b/config/matching_params.yml
@@ -105,6 +105,50 @@ material_description:
       - profondeur
       - désignation
 
+  en:
+    including_expressions:
+      - sand
+      - sandy
+      - sandstone
+      - silt
+      - silty
+      - siltstone
+      - clay
+      - claystone
+      - gravel
+      - asphalt
+      - humus
+      - brown
+      - gray
+      - soft
+      - hard
+      - root
+      - surface layer
+      - stone
+      - beige
+      - concrete
+      - chalk
+      - marl
+      - core material
+      - limestone
+      - ditto
+      - alternation
+      - leucosome
+    excluding_expressions:
+      - Mr.
+      - end
+      - profile
+      - description
+      - signatures
+      - mixed sample
+      - project
+      - location
+      - address
+      - weather
+      - pump test
+      - core drilling
+      - drilling radius
+
 
 coordinate_keys:
   - Koordinaten
diff --git a/example/example_groundtruth.json b/example/example_groundtruth.json
index 53cca8bd..94b3c19e 100644
--- a/example/example_groundtruth.json
+++ b/example/example_groundtruth.json
@@ -1,18 +1,21 @@
 {
-    "example_borehole_profile.pdf": {
-        "groundwater": [],
-        "layers": [],
-        "metadata": {
-            "coordinates": {
-                "E": 615790,
-                "N": 157500
-            },
-            "drilling_date": "1995-09-03",
-            "drilling_methods": null,
-            "original_name": "",
-            "project_name": "",
-            "reference_elevation": 788.6,
-            "total_depth": null
+    "example_borehole_profile.pdf": [
+        {
+            "borehole_index": 0,
+            "groundwater": [],
+            "layers": [],
+            "metadata": {
+                "coordinates": {
+                    "E": 615790,
+                    "N": 157500
+                },
+                "drilling_date": "1995-09-03",
+                "drilling_methods": null,
+                "original_name": "",
+                "project_name": "",
+                "reference_elevation": 788.6,
+                "total_depth": null
+            }
         }
-    }
-}
+    ]
+}
\ No newline at end of file
diff --git a/example/example_gw_groundtruth.json b/example/example_gw_groundtruth.json
index 8090b45e..2ccb66ad 100644
--- a/example/example_gw_groundtruth.json
+++ b/example/example_gw_groundtruth.json
@@ -1,56 +1,80 @@
 {
-    "example_borehole_profile.pdf": {
-        "groundwater": [
-            {
-                "date": "2016-04-18",
-                "depth": 2.22,
-                "elevation": 448.07
-            },
-            {
-                "date": "2016-04-20",
-                "depth": 3.22,
-                "elevation": 447.07
+    "example_borehole_profile.pdf": [
+        {
+            "borehole_index": 0,
+            "groundwater": [
+                {
+                    "date": "2016-04-18",
+                    "depth": 2.22,
+                    "elevation": 448.07
+                },
+                {
+                    "date": "2016-04-20",
+                    "depth": 3.22,
+                    "elevation": 447.07
+                }
+            ],
+            "layers": [],
+            "metadata": {
+                "coordinates": {
+                    "E": 615790,
+                    "N": 157500
+                },
+                "drilling_date": "1995-09-03",
+                "drilling_methods": null,
+                "original_name": "",
+                "project_name": "",
+                "reference_elevation": 788.6,
+                "total_depth": null
             }
-        ],
-        "layers": [],
-        "metadata": {
-            "coordinates": {
-                "E": 615790,
-                "N": 157500
-            },
-            "drilling_date": "1995-09-03",
-            "drilling_methods": null,
-            "original_name": "",
-            "project_name": "",
-            "reference_elevation": 788.6,
-            "total_depth": null
         }
-    },
-    "example_borehole_profile_2.pdf": {
-        "groundwater": [
-            {
-                "date": "2016-04-18",
-                "depth": 2.22,
-                "elevation": 448.07
-            },
-            {
-                "date": "2016-04-20",
-                "depth": 3.22,
-                "elevation": 447.07
+    ],
+    "example_borehole_profile_2.pdf": [
+        {
+            "borehole_index": 0,
+            "groundwater": [
+                {
+                    "date": "2016-04-20",
+                    "depth": 3.22,
+                    "elevation": 447.07
+                }
+            ],
+            "layers": [],
+            "metadata": {
+                "coordinates": {
+                    "E": 615790,
+                    "N": 157500
+                },
+                "drilling_date": "1995-09-03",
+                "drilling_methods": null,
+                "original_name": "",
+                "project_name": "",
+                "reference_elevation": 788.6,
+                "total_depth": null
+            }
+        },
+        {
+            "borehole_index": 1,
+            "groundwater": [
+                {
+                    "date": "2016-04-18",
+                    "depth": 2.22,
+                    "elevation": 448.07
+                }
+            ],
+            "layers": [],
+            "metadata": {
+                "coordinates": {
+                    "E": 615790,
+                    "N": 157500
+                },
+                "drilling_date": "1995-09-03",
+                "drilling_methods": null,
+                "original_name": "",
+                "project_name": "",
+                "reference_elevation": 788.6,
+                "total_depth": null
             }
-        ],
-        "layers": [],
-        "metadata": {
-            "coordinates": {
-                "E": 615790,
-                "N": 157500
-            },
-            "drilling_date": "1995-09-03",
-            "drilling_methods": null,
-            "original_name": "",
-            "project_name": "",
-            "reference_elevation": 788.6,
-            "total_depth": null
         }
-    }
-}
+    ]
+}
\ No newline at end of file
diff --git a/example/example_layers_groundtruth.json b/example/example_layers_groundtruth.json
new file mode 100644
index 00000000..c81d7370
--- /dev/null
+++ b/example/example_layers_groundtruth.json
@@ -0,0 +1,75 @@
+{
+    "example_borehole_profile.pdf": [
+        {
+            "borehole_index": 0,
+            "groundwater": [],
+            "layers": [
+                {
+                    "depth_interval": {
+                        "end": 0.5,
+                        "start": 0.0
+                    },
+                    "material_description": "KIES, Sand"
+                },
+                {
+                    "depth_interval": {
+                        "end": 2.0,
+                        "start": 0.5
+                    },
+                    "material_description": "stein, sand"
+                }
+            ],
+            "metadata": {
+                "coordinates": {
+                    "E": 615790,
+                    "N": 157500
+                },
+                "drilling_date": "1995-09-03",
+                "drilling_methods": null,
+                "original_name": "",
+                "project_name": "",
+                "reference_elevation": 788.6,
+                "total_depth": null
+            }
+        },
+        {
+            "borehole_index": 1,
+            "groundwater": [],
+            "layers": [
+                {
+                    "depth_interval": {
+                        "end": 1,
+                        "start": null
+                    },
+                    "material_description": "HUMUS"
+                },
+                {
+                    "depth_interval": {
+                        "end": 2,
+                        "start": 1
+                    },
+                    "material_description": "KIES, grau"
+                },
+                {
+                    "depth_interval": {
+                        "end": 3,
+                        "start": 2
+                    },
+                    "material_description": "sand"
+                }
+            ],
+            "metadata": {
+                "coordinates": {
+                    "E": 615790,
+                    "N": 157500
+                },
+                "drilling_date": "1995-09-03",
+                "drilling_methods": null,
+                "original_name": "",
+                "project_name": "",
+                "reference_elevation": 788.6,
+                "total_depth": null
+            }
+        }
+    ]
+}
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 57ba8136..106c6222 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "swissgeol-boreholes-dataextraction"
 version = "0.0.1-dev"
 description = "Python project to analyse borehole profiles."
 readme = "README.md"
-requires-python = ">=3.11"
+requires-python = ">=3.11,<3.13"
 dependencies = [
     "boto3",
     "pandas",
diff --git a/src/app/api/v1/endpoints/extract_data.py b/src/app/api/v1/endpoints/extract_data.py
index 467c9702..69acb78b 100644
--- a/src/app/api/v1/endpoints/extract_data.py
+++ b/src/app/api/v1/endpoints/extract_data.py
@@ -124,18 +124,18 @@ def extract_coordinates(extract_data_request: ExtractDataRequest, text_lines: li
         system (LV03 or LV95). The bounding box is in PDF coordinates.
     """
 
-    def create_response(coord, srs):
+    def create_response(coord_feature, srs):
         bbox = BoundingBox(
-            x0=coord.rect.x0,
-            y0=coord.rect.y0,
-            x1=coord.rect.x1,
-            y1=coord.rect.y1,
+            x0=coord_feature.rect.x0,
+            y0=coord_feature.rect.y0,
+            x1=coord_feature.rect.x1,
+            y1=coord_feature.rect.y1,
         )
         return ExtractCoordinatesResponse(
             bbox=bbox,
             coordinates=Coordinates(
-                east=coord.east.coordinate_value,
-                north=coord.north.coordinate_value,
+                east=coord_feature.feature.east.coordinate_value,
+                north=coord_feature.feature.north.coordinate_value,
                 projection=srs,
             ),
         )
@@ -143,11 +143,13 @@ def create_response(coord, srs):
     coord_extractor = CoordinateExtractor()
     extracted_coord = coord_extractor.extract_coordinates_aggregated(text_lines, extract_data_request.page_number)
 
-    if isinstance(extracted_coord, LV03Coordinate):
-        return create_response(extracted_coord, "LV03")
+    if extracted_coord:
+        extracted_coord = extracted_coord[0]  # currently we only handle one set of coordinate
+        if isinstance(extracted_coord.feature, LV03Coordinate):
+            return create_response(extracted_coord, "LV03")
 
-    if isinstance(extracted_coord, LV95Coordinate):
-        return create_response(extracted_coord, "LV95")
+        if isinstance(extracted_coord.feature, LV95Coordinate):
+            return create_response(extracted_coord, "LV95")
 
     raise HTTPException(status_code=404, detail="Coordinates not found.")
 
diff --git a/src/app/common/config.py b/src/app/common/config.py
index 174c9632..738698dc 100644
--- a/src/app/common/config.py
+++ b/src/app/common/config.py
@@ -3,8 +3,11 @@
 import logging
 import os
 
+import dotenv
 from pydantic_settings import BaseSettings, SettingsConfigDict
 
+dotenv.load_dotenv(override=True)
+
 
 def get_aws_bucket_name() -> str:
     """Get the AWS bucket name."""
diff --git a/src/scripts/convert_ground_truth_v2.py b/src/scripts/convert_ground_truth_v2.py
new file mode 100644
index 00000000..2c99c89b
--- /dev/null
+++ b/src/scripts/convert_ground_truth_v2.py
@@ -0,0 +1,56 @@
+"""Second script to convert ground truth file into the second new format (with list of boreholes)."""
+
+import json
+
+import click
+
+
+def transform_json(input_json):
+    """Transforms the input JSON structure to ensure each PDF entry contains a list of boreholes.
+
+    Args:
+        input_json (dict): The original JSON structure.
+
+    Returns:
+        dict: The transformed JSON structure.
+    """
+    output_json = {}
+
+    for pdf, borehole_data in input_json.items():
+        # Wrap the borehole data into a list with an index
+        output_json[pdf] = [{"borehole_index": 0, **borehole_data}]
+
+    return output_json
+
+
+@click.command()
+@click.option("-g", "--ground_truth_path", type=click.Path(exists=True))
+@click.option("-o", "--out_path", type=click.Path())
+def convert_ground_truth_v2(ground_truth_path, out_path):
+    """CLI command to transform borehole JSON structure and save the output.
+
+    Probably works only after the first convert_ground_truth.py has been applied.
+
+    Args:
+        ground_truth_path: Path to the input JSON file.
+        out_path: Path to save the transformed JSON file.
+
+    Usage:
+    ```
+    python src/scripts/convert_ground_truth_v2.py -g ./data/geoquat_old_ground_truth.json \
+    -o ./data/geoquat_new_ground_truth.json
+    ```
+    """
+    with open(ground_truth_path) as file:
+        data = json.load(file)
+
+    transformed_data = transform_json(data)
+
+    with open(out_path, "w", encoding="utf-8") as file:
+        json.dump(transformed_data, file, indent=4, ensure_ascii=False)
+
+    click.echo(f"Transformation complete. Output saved to {out_path}")
+
+
+if __name__ == "__main__":
+    convert_ground_truth_v2()
diff --git a/src/stratigraphy/annotations/draw.py b/src/stratigraphy/annotations/draw.py
index dc5bddb0..b5df3c04 100644
--- a/src/stratigraphy/annotations/draw.py
+++ b/src/stratigraphy/annotations/draw.py
@@ -7,13 +7,9 @@
 import fitz
 import pandas as pd
 from dotenv import load_dotenv
-from stratigraphy.data_extractor.data_extractor import FeatureOnPage
-from stratigraphy.depths_materials_column_pairs.bounding_boxes import BoundingBoxes
-from stratigraphy.groundwater.groundwater_extraction import Groundwater
+from stratigraphy.depths_materials_column_pairs.bounding_boxes import PageBoundingBoxes
 from stratigraphy.layer.layer import Layer
-from stratigraphy.metadata.coordinate_extraction import Coordinate
-from stratigraphy.metadata.elevation_extraction import Elevation
-from stratigraphy.util.predictions import OverallFilePredictions
+from stratigraphy.util.overall_file_predictions import OverallFilePredictions
 
 load_dotenv()
 
@@ -52,175 +48,93 @@ def draw_predictions(
     if directory.is_file():  # deal with the case when we pass a file instead of a directory
         directory = directory.parent
     for file_prediction in predictions.file_predictions_list:
-        logger.info("Drawing predictions for file %s", file_prediction.file_name)
-
-        bounding_boxes = file_prediction.bounding_boxes
-        coordinates = file_prediction.metadata.coordinates
-        elevation = file_prediction.metadata.elevation
-
-        # Assess the correctness of the metadata
-        if (
-            document_level_metadata_metrics is not None
-            and file_prediction.file_name in document_level_metadata_metrics.index
-        ):
-            is_coordinates_correct = document_level_metadata_metrics.loc[file_prediction.file_name].coordinate
-            is_elevation_correct = document_level_metadata_metrics.loc[file_prediction.file_name].elevation
-        else:
-            logger.warning(
-                "Metrics for file %s not found in document_level_metadata_metrics.", file_prediction.file_name
-            )
-            is_coordinates_correct = None
-            is_elevation_correct = None
-
+        filename = file_prediction.file_name
+        logger.info("Drawing predictions for file %s", filename)
         try:
-            with fitz.Document(directory / file_prediction.file_name) as doc:
+            with fitz.Document(directory / filename) as doc:
                 for page_index, page in enumerate(doc):
                     page_number = page_index + 1
                     shape = page.new_shape()  # Create a shape object for drawing
-                    if page_number == 1:
-                        draw_metadata(
+
+                    # iterate over all boreholes identified
+                    for borehole_predictions in file_prediction.borehole_predictions_list:
+                        bounding_boxes = borehole_predictions.bounding_boxes
+                        coordinates = borehole_predictions.metadata.coordinates
+                        elevation = borehole_predictions.metadata.elevation
+                        groundwaters = borehole_predictions.groundwater_in_borehole
+                        bh_layers = borehole_predictions.layers_in_borehole
+
+                        if coordinates is not None and page_number == coordinates.page:
+                            draw_feature(
+                                shape,
+                                coordinates.rect * page.derotation_matrix,
+                                coordinates.feature.is_correct,
+                                "purple",
+                            )
+                        if elevation is not None and page_number == elevation.page:
+                            draw_feature(
+                                shape, elevation.rect * page.derotation_matrix, elevation.feature.is_correct, "blue"
+                            )
+                        for groundwater_entry in groundwaters.groundwater_feature_list:
+                            if page_number == groundwater_entry.page:
+                                # TODO: store correctness with the groundwater object
+                                # See also https://github.com/swisstopo/swissgeol-boreholes-dataextraction/issues/124
+                                draw_feature(shape, groundwater_entry.rect * page.derotation_matrix, None, "pink")
+                        draw_depth_columns_and_material_rect(
                             shape,
                             page.derotation_matrix,
-                            page.rotation,
-                            coordinates,
-                            is_coordinates_correct,
-                            elevation,
-                            is_elevation_correct,
+                            [bboxes for bboxes in bounding_boxes if bboxes.page == page_number],
                         )
-                    if coordinates is not None and page_number == coordinates.page:
-                        draw_coordinates(shape, coordinates)
-                    if elevation is not None and page_number == elevation.page:
-                        draw_elevation(shape, elevation)
-                    for groundwater_entry in file_prediction.groundwater.groundwater:
-                        if page_number == groundwater_entry.page:
-                            draw_groundwater(shape, groundwater_entry)
-                    draw_depth_columns_and_material_rect(
-                        shape,
-                        page.derotation_matrix,
-                        [bboxes for bboxes in bounding_boxes if bboxes.page == page_number],
-                    )
-                    draw_material_descriptions(
-                        shape,
-                        page.derotation_matrix,
-                        [
-                            layer
-                            for layer in file_prediction.layers_in_document.layers
-                            if layer.material_description.page == page_number
-                        ],
-                    )
-                    shape.commit()  # Commit all the drawing operations to the page
+                        draw_material_descriptions(
+                            shape,
+                            page.derotation_matrix,
+                            [layer for layer in bh_layers.layers if layer.material_description.page == page_number],
+                        )
+                        shape.commit()  # Commit all the drawing operations to the page
 
-                    tmp_file_path = out_directory / f"{file_prediction.file_name}_page{page_number}.png"
-                    fitz.utils.get_pixmap(page, matrix=fitz.Matrix(2, 2), clip=page.rect).save(tmp_file_path)
+                        tmp_file_path = out_directory / f"{filename}_page{page_number}.png"
+                        fitz.utils.get_pixmap(page, matrix=fitz.Matrix(2, 2), clip=page.rect).save(tmp_file_path)
 
-                    if mlflow_tracking:  # This is only executed if MLFlow tracking is enabled
-                        try:
-                            import mlflow
+                        if mlflow_tracking:  # This is only executed if MLFlow tracking is enabled
+                            try:
+                                import mlflow
 
-                            mlflow.log_artifact(tmp_file_path, artifact_path="pages")
-                        except NameError:
-                            logger.warning("MLFlow could not be imported. Skipping logging of artifact.")
+                                mlflow.log_artifact(tmp_file_path, artifact_path="pages")
+                            except NameError:
+                                logger.warning("MLFlow could not be imported. Skipping logging of artifact.")
 
         except (FileNotFoundError, fitz.FileDataError) as e:
-            logger.error("Error opening file %s: %s", file_prediction.file_name, e)
+            logger.error("Error opening file %s: %s", filename, e)
             continue
 
-        logger.info("Finished drawing predictions for file %s", file_prediction.file_name)
-
+        logger.info("Finished drawing predictions for file %s", filename)
 
-def draw_metadata(
-    shape: fitz.Shape,
-    derotation_matrix: fitz.Matrix,
-    rotation: float,
-    coordinates: Coordinate | None,
-    is_coordinate_correct: bool | None,
-    elevation_info: Elevation | None,
-    is_elevation_correct: bool | None,
-) -> None:
-    """Draw the extracted metadata on the top of the given PDF page.
 
-    The data is always drawn at the top-left of the page, without considering where on the page the data was originally
-    found / extracted from.
+def draw_feature(shape: fitz.Shape, rect: fitz.Rect, is_correct: bool | None, color: str) -> None:
+    """Draw a bounding box around the area of the page where some feature was extracted from.
 
     Args:
         shape (fitz.Shape): The shape object for drawing.
-        derotation_matrix (fitz.Matrix): The derotation matrix of the page.
-        rotation (float): The rotation of the page.
-        coordinates (Coordinate | None): The coordinate object to draw.
-        is_coordinate_correct (bool  | None): Whether the coordinate information is correct.
-        elevation_info (Elevation | None): The elevation information to draw.
-        is_elevation_correct (bool | None): Whether the elevation information is correct.
+        rect (fitz:Rect): The bounding box of the feature to draw.
+        is_correct (bool | None): whether the feature has been evaluated as correct against the ground truth
+        color (str): The name of the color to use for the bounding box of the feature.
     """
-    coordinate_rect = fitz.Rect([5, 5, 250, 30])
-    elevation_rect = fitz.Rect([5, 30, 250, 55])
-
-    shape.draw_rect(coordinate_rect * derotation_matrix)
-    shape.finish(fill=fitz.utils.getColor("gray"), fill_opacity=0.5)
-    shape.insert_textbox(coordinate_rect * derotation_matrix, f"Coordinates: {coordinates}", rotate=rotation)
-    if is_coordinate_correct is not None:
-        # TODO associate correctness with the extracted coordinates in a better way
-        coordinate_color = "green" if is_coordinate_correct else "red"
-        shape.draw_line(
-            coordinate_rect.top_left * derotation_matrix,
-            coordinate_rect.bottom_left * derotation_matrix,
-        )
-        shape.finish(
-            color=fitz.utils.getColor(coordinate_color),
-            width=6,
-            stroke_opacity=0.5,
-        )
+    shape.draw_rect(rect)
+    shape.finish(color=fitz.utils.getColor(color))
 
-    # Draw the bounding box around the elevation information
-    elevation_txt = f"Elevation: {elevation_info.elevation} m" if elevation_info is not None else "Elevation: N/A"
-    shape.draw_rect(elevation_rect * derotation_matrix)
-    shape.finish(fill=fitz.utils.getColor("gray"), fill_opacity=0.5)
-    shape.insert_textbox(elevation_rect * derotation_matrix, elevation_txt, rotate=rotation)
-    if is_elevation_correct is not None:
-        elevation_color = "green" if is_elevation_correct else "red"
+    if is_correct is not None:
+        correct_color = "green" if is_correct else "red"
         shape.draw_line(
-            elevation_rect.top_left * derotation_matrix,
-            elevation_rect.bottom_left * derotation_matrix,
+            rect.top_left,
+            rect.bottom_left,
         )
         shape.finish(
-            color=fitz.utils.getColor(elevation_color),
+            color=fitz.utils.getColor(correct_color),
             width=6,
             stroke_opacity=0.5,
         )
 
 
-def draw_coordinates(shape: fitz.Shape, coordinates: Coordinate) -> None:
-    """Draw a bounding box around the area of the page where the coordinates were extracted from.
-
-    Args:
-        shape (fitz.Shape): The shape object for drawing.
-        coordinates (Coordinate): The coordinate object to draw.
-    """
-    shape.draw_rect(coordinates.rect)
-    shape.finish(color=fitz.utils.getColor("purple"))
-
-
-def draw_groundwater(shape: fitz.Shape, groundwater_entry: FeatureOnPage[Groundwater]) -> None:
-    """Draw a bounding box around the area of the page where the groundwater information was extracted from.
-
-    Args:
-        shape (fitz.Shape): The shape object for drawing.
-        groundwater_entry (FeatureOnPage[Groundwater]): The groundwater information to draw.
-    """
-    shape.draw_rect(groundwater_entry.rect)
-    shape.finish(color=fitz.utils.getColor("pink"))
-
-
-def draw_elevation(shape: fitz.Shape, elevation: Elevation) -> None:
-    """Draw a bounding box around the area of the page where the elevation were extracted from.
-
-    Args:
-        shape (fitz.Shape): The shape object for drawing.
-        elevation (Elevation): The elevation information to draw.
-    """
-    shape.draw_rect(elevation.rect)
-    shape.finish(color=fitz.utils.getColor("blue"))
-
-
 def draw_material_descriptions(shape: fitz.Shape, derotation_matrix: fitz.Matrix, layers: list[Layer]) -> None:
     """Draw information about material descriptions on a pdf page.
 
@@ -244,7 +158,7 @@ def draw_material_descriptions(shape: fitz.Shape, derotation_matrix: fitz.Matrix
 
 
 def draw_depth_columns_and_material_rect(
-    shape: fitz.Shape, derotation_matrix: fitz.Matrix, bounding_boxes: list[BoundingBoxes]
+    shape: fitz.Shape, derotation_matrix: fitz.Matrix, bounding_boxes: list[PageBoundingBoxes]
 ):
     """Draw depth columns as well as the material rects on a pdf page.
 
diff --git a/src/stratigraphy/benchmark/ground_truth.py b/src/stratigraphy/benchmark/ground_truth.py
index c53aeaa3..4bed7832 100644
--- a/src/stratigraphy/benchmark/ground_truth.py
+++ b/src/stratigraphy/benchmark/ground_truth.py
@@ -14,25 +14,32 @@ class GroundTruth:
     """Ground truth data for the stratigraphy benchmark."""
 
     def __init__(self, path: Path) -> None:
-        self.ground_truth = defaultdict(dict)
+        """Instanciate the GroundTruth object.
+
+        Args:
+            path (Path): the path to the Ground truth file
+        """
+        self.ground_truth = defaultdict(lambda: defaultdict(dict))
 
         # Load the ground truth data
         with open(path, encoding="utf-8") as in_file:
             ground_truth = json.load(in_file)
 
         # Parse the ground truth data
-        for borehole_profile, ground_truth_item in ground_truth.items():
-            layers = ground_truth_item["layers"]
-            self.ground_truth[borehole_profile]["layers"] = [
-                {
-                    "material_description": parse_text(layer["material_description"]),
-                    "depth_interval": layer["depth_interval"],
-                }
-                for layer in layers
-                if parse_text(layer["material_description"]) != ""
-            ]
-            self.ground_truth[borehole_profile]["metadata"] = ground_truth_item["metadata"]
-            self.ground_truth[borehole_profile]["groundwater"] = ground_truth_item["groundwater"]
+        for file_name, ground_truth_item in ground_truth.items():
+            for borehole_data in ground_truth_item:
+                layers = borehole_data["layers"]
+                borehole_index = borehole_data["borehole_index"]
+                self.ground_truth[file_name][borehole_index]["layers"] = [
+                    {
+                        "material_description": parse_text(layer["material_description"]),
+                        "depth_interval": layer["depth_interval"],
+                    }
+                    for layer in layers
+                    if parse_text(layer["material_description"]) != ""
+                ]
+                self.ground_truth[file_name][borehole_index]["metadata"] = borehole_data["metadata"]
+                self.ground_truth[file_name][borehole_index]["groundwater"] = borehole_data["groundwater"]
 
     def for_file(self, file_name: str) -> dict:
         """Get the ground truth data for a given file.
diff --git a/src/stratigraphy/benchmark/score.py b/src/stratigraphy/benchmark/score.py
index ffee3eec..bfc20605 100644
--- a/src/stratigraphy/benchmark/score.py
+++ b/src/stratigraphy/benchmark/score.py
@@ -10,7 +10,7 @@
 from dotenv import load_dotenv
 from stratigraphy import DATAPATH
 from stratigraphy.benchmark.ground_truth import GroundTruth
-from stratigraphy.util.predictions import OverallFilePredictions
+from stratigraphy.util.overall_file_predictions import OverallFilePredictions
 
 load_dotenv()
 
@@ -38,30 +38,11 @@ def evaluate(
 
     ground_truth = GroundTruth(ground_truth_path)
 
-    #############################
-    # Evaluate the borehole extraction metadata
-    #############################
-    metadata_metrics_list = predictions.evaluate_metadata_extraction(ground_truth)
-    metadata_metrics = metadata_metrics_list.get_cumulated_metrics()
-    document_level_metadata_metrics: pd.DataFrame = metadata_metrics_list.get_document_level_metrics()
-    document_level_metadata_metrics.to_csv(
-        temp_directory / "document_level_metadata_metrics.csv", index_label="document_name"
-    )  # mlflow.log_artifact expects a file
-
-    # print the metrics
-    logger.info("Metadata Performance metrics:")
-    logger.info(metadata_metrics.to_json())
-
-    if mlflow_tracking:
-        import mlflow
-
-        mlflow.log_metrics(metadata_metrics.to_json())
-        mlflow.log_artifact(temp_directory / "document_level_metadata_metrics.csv")
-
     #############################
     # Evaluate the borehole extraction
     #############################
-    metrics = predictions.evaluate_geology(ground_truth)
+    matched_with_ground_truth = predictions.match_with_ground_truth(ground_truth)
+    metrics = matched_with_ground_truth.evaluate_geology()
 
     metrics.document_level_metrics_df().to_csv(
         temp_directory / "document_level_metrics.csv", index_label="document_name"
@@ -73,9 +54,29 @@ def evaluate(
     logger.info("Performance metrics: %s", formatted_metrics)
 
     if mlflow_tracking:
+        import mlflow
+
         mlflow.log_metrics(metrics_dict)
         mlflow.log_artifact(temp_directory / "document_level_metrics.csv")
 
+    #############################
+    # Evaluate the borehole extraction metadata
+    #############################
+    metadata_metrics_list = matched_with_ground_truth.evaluate_metadata_extraction()
+    metadata_metrics = metadata_metrics_list.get_cumulated_metrics()
+    document_level_metadata_metrics: pd.DataFrame = metadata_metrics_list.get_document_level_metrics()
+    document_level_metadata_metrics.to_csv(
+        temp_directory / "document_level_metadata_metrics.csv", index_label="document_name"
+    )  # mlflow.log_artifact expects a file
+
+    # print the metrics
+    logger.info("Metadata Performance metrics:")
+    logger.info(metadata_metrics.to_json())
+
+    if mlflow_tracking:
+        mlflow.log_metrics(metadata_metrics.to_json())
+        mlflow.log_artifact(temp_directory / "document_level_metadata_metrics.csv")
+
     return document_level_metadata_metrics
 
 
diff --git a/src/stratigraphy/depth/interval.py b/src/stratigraphy/depth/interval.py
index fa7d41c2..cf8f361d 100644
--- a/src/stratigraphy/depth/interval.py
+++ b/src/stratigraphy/depth/interval.py
@@ -17,6 +17,9 @@ def __init__(self, start: DepthColumnEntry | None, end: DepthColumnEntry | None)
         self.start = start
         self.end = end
 
+    def __repr__(self):
+        return f"({self.start}, {self.end})"
+
 
 class AAboveBInterval(Interval):
     """Class for depth intervals where the upper depth is located above the lower depth on the page."""
diff --git a/src/stratigraphy/depths_materials_column_pairs/bounding_boxes.py b/src/stratigraphy/depths_materials_column_pairs/bounding_boxes.py
index ba1ebcd6..834de75f 100644
--- a/src/stratigraphy/depths_materials_column_pairs/bounding_boxes.py
+++ b/src/stratigraphy/depths_materials_column_pairs/bounding_boxes.py
@@ -33,7 +33,7 @@ def from_json(cls, data) -> "BoundingBox":
 
 
 @dataclass
-class BoundingBoxes:
+class PageBoundingBoxes:
     """A class to represent the bounding boxes of sidebars and associated material descriptions."""
 
     sidebar_bbox: BoundingBox | None
@@ -55,10 +55,12 @@ def to_json(self) -> dict:
         }
 
     @classmethod
-    def from_json(cls, data) -> "BoundingBoxes":
+    def from_json(cls, data) -> "PageBoundingBoxes":
         """Convert a JSON data structure to a BoundingBoxes object."""
         return cls(
-            sidebar_bbox=BoundingBox.from_json(data["sidebar_rect"]) if "sidebar_rect" in data else None,
+            sidebar_bbox=BoundingBox.from_json(data["sidebar_rect"])
+            if "sidebar_rect" in data and data["sidebar_rect"]
+            else None,
             depth_column_entry_bboxes=[BoundingBox.from_json(entry) for entry in data["depth_column_entries"]],
             material_description_bbox=BoundingBox.from_json(data["material_description_rect"]),
             page=data["page"],
@@ -67,7 +69,7 @@ def from_json(cls, data) -> "BoundingBoxes":
     @classmethod
     def from_material_description_rect_with_sidebar(
         cls, pair: MaterialDescriptionRectWithSidebar, page_number: int
-    ) -> "BoundingBoxes":
+    ) -> "PageBoundingBoxes":
         """Convert a MaterialDescriptionRectWithSidebar instance to a BoundingBoxes object."""
         if pair.sidebar:
             depth_column_bbox = BoundingBox(pair.sidebar.rect())
@@ -75,7 +77,7 @@ def from_material_description_rect_with_sidebar(
         else:
             depth_column_bbox = None
             depth_column_entry_bboxes = []
-        return BoundingBoxes(
+        return PageBoundingBoxes(
             sidebar_bbox=depth_column_bbox,
             depth_column_entry_bboxes=depth_column_entry_bboxes,
             material_description_bbox=BoundingBox(pair.material_description_rect),
diff --git a/src/stratigraphy/evaluation/evaluation_dataclasses.py b/src/stratigraphy/evaluation/evaluation_dataclasses.py
index c61f181e..b7167e54 100644
--- a/src/stratigraphy/evaluation/evaluation_dataclasses.py
+++ b/src/stratigraphy/evaluation/evaluation_dataclasses.py
@@ -114,7 +114,7 @@ def get_document_level_metrics(self) -> pd.DataFrame:
 class OverallBoreholeMetadataMetrics(metaclass=abc.ABCMeta):
     """Metrics for borehole metadata."""
 
-    borehole_metadata_metrics: list[FileBoreholeMetadataMetrics]
+    borehole_metadata_metrics: list[BoreholeMetadataMetrics]
 
     def __init__(self):
         """Initializes the OverallBoreholeMetadataMetrics object."""
diff --git a/src/stratigraphy/evaluation/groundwater_evaluator.py b/src/stratigraphy/evaluation/groundwater_evaluator.py
index dcd0b344..2ada6deb 100644
--- a/src/stratigraphy/evaluation/groundwater_evaluator.py
+++ b/src/stratigraphy/evaluation/groundwater_evaluator.py
@@ -2,11 +2,11 @@
 
 from dataclasses import dataclass
 
-from stratigraphy.benchmark.ground_truth import GroundTruth
 from stratigraphy.benchmark.metrics import OverallMetrics
 from stratigraphy.evaluation.evaluation_dataclasses import Metrics
 from stratigraphy.evaluation.utility import count_against_ground_truth
-from stratigraphy.groundwater.groundwater_extraction import Groundwater, GroundwaterInDocument
+from stratigraphy.groundwater.groundwater_extraction import Groundwater
+from stratigraphy.util.borehole_predictions import FileGroundwaterWithGroundTruth
 
 
 @dataclass
@@ -52,15 +52,17 @@ def groundwater_depth_metrics_to_overall_metrics(self):
 class GroundwaterEvaluator:
     """Class for evaluating the extracted groundwater information of a borehole."""
 
-    def __init__(self, groundwater_entries: list[GroundwaterInDocument], ground_truth: GroundTruth):
+    def __init__(
+        self,
+        groundwater_list: list[FileGroundwaterWithGroundTruth],
+    ):
         """Initializes the GroundwaterEvaluator object.
 
         Args:
-            groundwater_entries (list[GroundwaterInDocument]): The metadata to evaluate.
-            ground_truth (GroundTruth): The ground truth.
+            groundwater_list (list[FileGroundwaterWithGroundTruth]): A list of extracted groundwater data per
+                borehole, together with the ground truth data associated to it.
         """
-        self.ground_truth = ground_truth
-        self.groundwater_entries: list[GroundwaterInDocument] = groundwater_entries
+        self.groundwater_list = groundwater_list
 
     def evaluate(self) -> OverallGroundwaterMetrics:
         """Evaluate the groundwater information of the file against the ground truth.
@@ -70,56 +72,64 @@ def evaluate(self) -> OverallGroundwaterMetrics:
         """
         overall_groundwater_metrics = OverallGroundwaterMetrics()
 
-        for groundwater_in_doc in self.groundwater_entries:
-            filename = groundwater_in_doc.filename
-            ground_truth_data = self.ground_truth.for_file(filename)
-            if ground_truth_data is None or ground_truth_data.get("groundwater") is None:
-                ground_truth = []  # If no ground truth is available, set it to an empty list
-            else:
-                ground_truth = ground_truth_data.get("groundwater")
-
-            ############################################################################################################
-            ### Compute the metadata correctness for the groundwater information.
-            ############################################################################################################
-            gt_groundwater = [
-                Groundwater.from_json_values(
-                    depth=json_gt_data.get("depth"),
-                    date=json_gt_data.get("date"),
-                    elevation=json_gt_data.get("elevation"),
-                )
-                for json_gt_data in ground_truth
-            ]
-
-            groundwater_metrics = count_against_ground_truth(
-                [
-                    (
-                        entry.feature.depth,
-                        entry.feature.format_date(),
-                        entry.feature.elevation,
+        for file in self.groundwater_list:
+            # lists to contain the metrics
+            groundwater_metrics_list = []
+            groundwater_depth_metrics_list = []
+            groundwater_elevation_metrics_list = []
+            groundwater_date_metrics_list = []
+
+            for borehole_data in file.boreholes:
+                ############################################################################################################
+                ### Compute the metadata correctness for the groundwater information.
+                ############################################################################################################
+                gt_groundwater = [
+                    Groundwater.from_json_values(
+                        depth=json_gt_data.get("depth"),
+                        date=json_gt_data.get("date"),
+                        elevation=json_gt_data.get("elevation"),
                     )
-                    for entry in groundwater_in_doc.groundwater
-                ],
-                [(entry.depth, entry.format_date(), entry.elevation) for entry in gt_groundwater],
-            )
-            groundwater_depth_metrics = count_against_ground_truth(
-                [entry.feature.depth for entry in groundwater_in_doc.groundwater],
-                [entry.depth for entry in gt_groundwater],
-            )
-            groundwater_elevation_metrics = count_against_ground_truth(
-                [entry.feature.elevation for entry in groundwater_in_doc.groundwater],
-                [entry.elevation for entry in gt_groundwater],
-            )
-            groundwater_date_metrics = count_against_ground_truth(
-                [entry.feature.format_date() for entry in groundwater_in_doc.groundwater],
-                [entry.format_date() for entry in gt_groundwater],
-            )
+                    for json_gt_data in borehole_data.ground_truth
+                ]
+
+                # TODO store the correctness directly on the Groundwater objects, so we can use that in the
+                # visualizations (cf. https://github.com/swisstopo/swissgeol-boreholes-dataextraction/issues/124)
+                entries = borehole_data.groundwater.groundwater_feature_list if borehole_data.groundwater else []
+                groundwater_metrics = count_against_ground_truth(
+                    [
+                        (
+                            entry.feature.depth,
+                            entry.feature.format_date(),
+                            entry.feature.elevation,
+                        )
+                        for entry in entries
+                    ],
+                    [(entry.depth, entry.format_date(), entry.elevation) for entry in gt_groundwater],
+                )
+                groundwater_depth_metrics = count_against_ground_truth(
+                    [entry.feature.depth for entry in entries],
+                    [entry.depth for entry in gt_groundwater],
+                )
+                groundwater_elevation_metrics = count_against_ground_truth(
+                    [entry.feature.elevation for entry in entries],
+                    [entry.elevation for entry in gt_groundwater],
+                )
+                groundwater_date_metrics = count_against_ground_truth(
+                    [entry.feature.format_date() for entry in entries],
+                    [entry.format_date() for entry in gt_groundwater],
+                )
+                groundwater_metrics_list.append(groundwater_metrics)
+                groundwater_depth_metrics_list.append(groundwater_depth_metrics)
+                groundwater_elevation_metrics_list.append(groundwater_elevation_metrics)
+                groundwater_date_metrics_list.append(groundwater_date_metrics)
 
+            # we take the micro-average across boreholes
             file_groundwater_metrics = GroundwaterMetrics(
-                groundwater_metrics=groundwater_metrics,
-                groundwater_depth_metrics=groundwater_depth_metrics,
-                groundwater_elevation_metrics=groundwater_elevation_metrics,
-                groundwater_date_metrics=groundwater_date_metrics,
-                filename=filename,
+                groundwater_metrics=Metrics.micro_average(groundwater_metrics_list),
+                groundwater_depth_metrics=Metrics.micro_average(groundwater_depth_metrics_list),
+                groundwater_elevation_metrics=Metrics.micro_average(groundwater_elevation_metrics_list),
+                groundwater_date_metrics=Metrics.micro_average(groundwater_date_metrics_list),
+                filename=file.filename,
             )
 
             overall_groundwater_metrics.add_groundwater_metrics(file_groundwater_metrics)
diff --git a/src/stratigraphy/evaluation/layer_evaluator.py b/src/stratigraphy/evaluation/layer_evaluator.py
index 914edf7a..22f449d9 100644
--- a/src/stratigraphy/evaluation/layer_evaluator.py
+++ b/src/stratigraphy/evaluation/layer_evaluator.py
@@ -1,14 +1,17 @@
 """Classes for evaluating the groundwater levels of a borehole."""
 
 import logging
+from collections import defaultdict
 from collections.abc import Callable
+from copy import deepcopy
 
 import Levenshtein
-from stratigraphy.benchmark.ground_truth import GroundTruth
 from stratigraphy.benchmark.metrics import OverallMetrics
 from stratigraphy.evaluation.evaluation_dataclasses import Metrics
 from stratigraphy.evaluation.utility import _is_valid_depth_interval
-from stratigraphy.layer.layer import Layer, LayersInDocument
+from stratigraphy.layer.layer import Layer, LayersInBorehole
+from stratigraphy.util.borehole_predictions import BoreholePredictionsWithGroundTruth, FileLayersWithGroundTruth
+from stratigraphy.util.file_predictions import FilePredictions
 from stratigraphy.util.util import parse_text
 
 logger = logging.getLogger(__name__)
@@ -17,17 +20,19 @@
 
 
 class LayerEvaluator:
-    """Class for evaluating the extracted groundwater information of a borehole."""
+    """Class for evaluating the layer information of all boreholes in a document."""
 
-    def __init__(self, layers_entries: list[LayersInDocument], ground_truth: GroundTruth):
+    def __init__(
+        self,
+        file_layers_list: list[FileLayersWithGroundTruth],
+    ):
         """Initializes the LayerEvaluator object.
 
         Args:
-            layers_entries (list[LayersInDocument]): The layers to evaluate.
-            ground_truth (GroundTruth): The ground truth.
+            file_layers_list (list[FileLayersWithGroundTruth]): The layers to evaluate, grouped by borehole in a list,
+                with associated ground truth data for each borehole.
         """
-        self.ground_truth = ground_truth
-        self.layers_entries: list[LayersInDocument] = layers_entries
+        self.file_layers_list = file_layers_list
 
     def get_layer_metrics(self) -> OverallMetrics:
         """Calculate metrics for layer predictions."""
@@ -68,42 +73,131 @@ def calculate_metrics(
         """
         overall_metrics = OverallMetrics()
 
-        for layers_in_document in self.layers_entries:
-            ground_truth_for_file = self.ground_truth.for_file(layers_in_document.filename)
-            number_of_truth_values = len(ground_truth_for_file["layers"])
-            hits = 0
-            total_predictions = 0
-
-            for layer in layers_in_document.layers:
-                if per_layer_action:
-                    per_layer_action(layer)
-                if per_layer_filter(layer):
-                    total_predictions += 1
-                    if per_layer_condition(layer):
-                        hits += 1
-
-            fn = 0
-            fn = number_of_truth_values - hits
-
-            if total_predictions > 0:
-                overall_metrics.metrics[layers_in_document.filename] = Metrics(
-                    tp=hits,
-                    fp=total_predictions - hits,
-                    fn=fn,
-                )
+        # iteration over all the files
+        for file in self.file_layers_list:
+            hits_for_all_borehole = 0
+            total_predictions_for_all_boreholes = 0
+            fn_for_all_boreholes = 0
+
+            for borehole_data in file.boreholes:
+                number_of_truth_values = len(borehole_data.ground_truth)
+                hits = 0
+                total_predictions = 0
+
+                layers = borehole_data.layers.layers if borehole_data.layers else []
+                for layer in layers:
+                    if per_layer_action:
+                        per_layer_action(layer)
+                    if per_layer_filter(layer):
+                        total_predictions += 1
+                        if per_layer_condition(layer):
+                            hits += 1
+
+                fn = number_of_truth_values - hits
+
+                hits_for_all_borehole += hits
+                total_predictions_for_all_boreholes += total_predictions
+                fn_for_all_boreholes += fn
+
+            # at this point we have the global statistics for all the boreholes in the document
+            overall_metrics.metrics[file.filename] = Metrics(
+                tp=hits_for_all_borehole,
+                fp=total_predictions_for_all_boreholes - hits_for_all_borehole,
+                fn=fn_for_all_boreholes,
+            )
 
         return overall_metrics
 
     @staticmethod
-    def evaluate_borehole(predicted_layers: list[Layer], ground_truth_layers: list):
+    def match_predictions_with_ground_truth(file_predictions: FilePredictions, ground_truth_for_file: dict):
         """Evaluate all predicted layers for a borehole against the ground truth.
 
+        Also performs the matching groundtruth to prediction when there is more than one borehole in the document.
+        It is for this reason that the layers are the first element that needs to be elaluated.
+
         Args:
-            predicted_layers (list[Layer]): The predicted layers for the borehole.
-            ground_truth_layers (list): The ground truth layers for the borehole.
+            file_predictions (FilePredictions): all predictions for the file
+            ground_truth_for_file (dict): the ground truth for the file
+
+        Returns:
+            list[BoreholePredictionsWithGroundTruth]
+        """
+        borehole_layers = [bh.layers_in_borehole for bh in file_predictions.borehole_predictions_list]
+        all_ground_truth_layers = {
+            idx: borehole_data["layers"] for idx, borehole_data in ground_truth_for_file.items()
+        }
+
+        pred_vs_gt_matching_score = defaultdict(dict)
+        # make a copy of borehole_layers to avoid modifying internal state during matching
+        borehole_layers_copy = deepcopy(borehole_layers)
+        for gt_idx, ground_truth_layers in all_ground_truth_layers.items():
+            for pred_idx, predicted_layers in enumerate(borehole_layers_copy):
+                # evaluation loop
+                matching_score = LayerEvaluator.compute_borehole_affinity(ground_truth_layers, predicted_layers)
+                pred_vs_gt_matching_score[gt_idx][pred_idx] = matching_score
+
+        # matching
+        result = []
+        assigned_preds = set()
+        while pred_vs_gt_matching_score:
+            max_score = float("-inf")
+            for gt_idx, pred_scores in pred_vs_gt_matching_score.items():
+                for pred_idx, score in pred_scores.items():
+                    if score > max_score and pred_idx not in assigned_preds:  # can't assign the same pred twice
+                        max_score = score
+                        best_matches = (gt_idx, pred_idx)
+
+            gt_best_idx, pred_best_idx = best_matches
+            result.append(
+                BoreholePredictionsWithGroundTruth(
+                    file_predictions.borehole_predictions_list[pred_best_idx], ground_truth_for_file[gt_best_idx]
+                )
+            )
+            assigned_preds.add(pred_best_idx)  # Mark this pred_idx as used
+
+            # Remove the matched gt_idx from consideration
+            del pred_vs_gt_matching_score[gt_best_idx]
+
+            if len(assigned_preds) == len(borehole_layers_copy):
+                # all preds have been assigned
+                break
+
+        # add entries with missing predictions for all unmatched ground truth boreholes (will count as false negatives)
+        for gt_idx in pred_vs_gt_matching_score:
+            result.append(
+                BoreholePredictionsWithGroundTruth(predictions=None, ground_truth=ground_truth_for_file[gt_idx])
+            )
+
+        # TODO properly disentangle the matching and the evaluations
+        # now compute the real statistics for the matched pairs
+        for borehole_data in result:
+            if borehole_data.predictions:
+                # This method makes an internal modification to borehole_layers
+                LayerEvaluator.compute_borehole_affinity(
+                    borehole_data.ground_truth["layers"], borehole_data.predictions.layers_in_borehole
+                )
+        return result
+
+    @staticmethod
+    def compute_borehole_affinity(ground_truth_layers: list[dict], predicted_layers: LayersInBorehole) -> float:
+        """Computes the matching score between a prediction and a groundtruth borehole.
+
+        This is relevant when there is more than one borehole per pdf. Computing this score allows to match the
+        predictions identified in the document against the correct groundtruth. The matching score is computed by
+        comparing the layers of each borehole identified to each layers in the groudtruth.
+
+        Args:
+            ground_truth_layers (list[dict]): list containing the ground truth for the layers
+            predicted_layers (LayersInBorehole): object containing the list of the predicted layers
+
+        Returns:
+            matching_score (float): a score that captures the similarity between the boreholes (1 is best, 0 is worst)
         """
         unmatched_layers = ground_truth_layers.copy()
-        for layer in predicted_layers:
+        matching_score = 0
+        if not predicted_layers.layers:
+            return 0
+        for layer in predicted_layers.layers:
             match, depth_interval_is_correct = LayerEvaluator.find_matching_layer(layer, unmatched_layers)
             if match:
                 layer.material_description.feature.is_correct = True
@@ -111,6 +205,10 @@ def evaluate_borehole(predicted_layers: list[Layer], ground_truth_layers: list):
             else:
                 layer.material_description.feature.is_correct = False
                 layer.is_correct = None
+            matching_score += 1 if depth_interval_is_correct else 0
+            matching_score += 1 if match else 0
+        matching_score /= 2 * len(predicted_layers.layers)
+        return matching_score
 
     @staticmethod
     def find_matching_layer(layer: Layer, unmatched_layers: list[dict]) -> tuple[dict, bool] | tuple[None, None]:
diff --git a/src/stratigraphy/evaluation/metadata_evaluator.py b/src/stratigraphy/evaluation/metadata_evaluator.py
index 8cc738b9..62c8a7ad 100644
--- a/src/stratigraphy/evaluation/metadata_evaluator.py
+++ b/src/stratigraphy/evaluation/metadata_evaluator.py
@@ -2,112 +2,155 @@
 
 import math
 
-from stratigraphy.benchmark.ground_truth import GroundTruth
 from stratigraphy.evaluation.evaluation_dataclasses import (
     FileBoreholeMetadataMetrics,
     Metrics,
     OverallBoreholeMetadataMetrics,
 )
-from stratigraphy.metadata.metadata import OverallBoreholeMetadata
+from stratigraphy.metadata.coordinate_extraction import Coordinate
+from stratigraphy.util.borehole_predictions import FileMetadataWithGroundTruth
 
 
 class MetadataEvaluator:
     """Class for evaluating the metadata of a borehole."""
 
-    metadata_list: OverallBoreholeMetadata
-    ground_truth: GroundTruth
-
-    def __init__(self, metadata_list: OverallBoreholeMetadata, ground_truth: GroundTruth) -> None:
+    def __init__(self, metadata_list: list[FileMetadataWithGroundTruth]) -> None:
         """Initializes the MetadataEvaluator object.
 
         Args:
-            metadata_list (OverallBoreholeMetadata): Container for multiple borehole metadata
-                objects to evaluate. Contains metadata_per_file for individual boreholes.
-            ground_truth (GroundTruth): The ground truth.
+            metadata_list (list[FileMetadataWithGroundTruth]): a list of borehole metadata predictions, with
+                ground truth data associated for every borehole.
         """
-        self.metadata_list: OverallBoreholeMetadata = metadata_list
-        self.ground_truth = ground_truth
+        self.metadata_list = metadata_list
 
     def evaluate(self) -> OverallBoreholeMetadataMetrics:
         """Evaluate the metadata of the file against the ground truth."""
         # Initialize the metadata correctness metrics
         metadata_metrics_list = OverallBoreholeMetadataMetrics()
 
-        for metadata in self.metadata_list.metadata_per_file:
-            ###########################################################################################################
-            ### Compute the metadata correctness for the coordinates.
-            ###########################################################################################################
-            extracted_coordinates = metadata.coordinates
-            ground_truth_coordinates = (
-                self.ground_truth.for_file(metadata.filename.name).get("metadata", {}).get("coordinates")
-            )
+        for file_data in self.metadata_list:
+            # create the lists that will contain the individual score of each borehole
+            elevation_metrics_list = []
+            coordinate_metrics_list = []
 
-            if extracted_coordinates and ground_truth_coordinates:
-                if extracted_coordinates.east.coordinate_value > 2e6 and ground_truth_coordinates["E"] < 2e6:
-                    ground_truth_east = int(ground_truth_coordinates["E"]) + 2e6
-                    ground_truth_north = int(ground_truth_coordinates["N"]) + 1e6
-                elif extracted_coordinates.east.coordinate_value < 2e6 and ground_truth_coordinates["E"] > 2e6:
-                    ground_truth_east = int(ground_truth_coordinates["E"]) - 2e6
-                    ground_truth_north = int(ground_truth_coordinates["N"]) - 1e6
-                else:
-                    ground_truth_east = int(ground_truth_coordinates["E"])
-                    ground_truth_north = int(ground_truth_coordinates["N"])
-
-                if (math.isclose(int(extracted_coordinates.east.coordinate_value), ground_truth_east, abs_tol=2)) and (
-                    math.isclose(int(extracted_coordinates.north.coordinate_value), ground_truth_north, abs_tol=2)
-                ):
-                    coordinate_metrics = Metrics(
-                        tp=1,
-                        fp=0,
-                        fn=0,
-                    )
-                else:
-                    coordinate_metrics = Metrics(
-                        tp=0,
-                        fp=1,
-                        fn=1,
-                    )
-            else:
-                coordinate_metrics = Metrics(
-                    tp=0,
-                    fp=1 if extracted_coordinates is not None else 0,
-                    fn=1 if ground_truth_coordinates is not None else 0,
+            for borehole_data in file_data.boreholes:
+                if borehole_data.ground_truth is None:
+                    # when the extraction detects more borehole than there actually is in the ground truth, the wosrt
+                    # predictions have no match and must be skipped for the evaluation
+                    continue
+
+                ###########################################################################################################
+                ### Compute the metadata correctness for the coordinates.
+                ###########################################################################################################
+                extracted_coordinates = (
+                    borehole_data.metadata.coordinates.feature
+                    if borehole_data.metadata and borehole_data.metadata.coordinates
+                    else None
+                )
+                ground_truth_coordinates = borehole_data.ground_truth.get("coordinates")
+
+                coordinate_metrics = self._evaluate_coordinate(extracted_coordinates, ground_truth_coordinates)
+                if borehole_data.metadata and borehole_data.metadata.coordinates:
+                    borehole_data.metadata.coordinates.feature.is_correct = coordinate_metrics.tp > 0
+                coordinate_metrics_list.append(coordinate_metrics)
+
+                ############################################################################################################
+                ### Compute the metadata correctness for the elevation.
+                ############################################################################################################
+                extracted_elevation = (
+                    borehole_data.metadata.elevation.feature.elevation
+                    if borehole_data.metadata and borehole_data.metadata.elevation
+                    else None
                 )
+                ground_truth_elevation = borehole_data.ground_truth.get("reference_elevation")
+                elevation_metrics = self._evaluate_elevation(extracted_elevation, ground_truth_elevation)
+                if borehole_data.metadata and borehole_data.metadata.elevation:
+                    borehole_data.metadata.elevation.feature.is_correct = elevation_metrics.tp > 0
+                elevation_metrics_list.append(elevation_metrics)
 
-            ############################################################################################################
-            ### Compute the metadata correctness for the elevation.
-            ############################################################################################################
-            extracted_elevation = None if metadata.elevation is None else metadata.elevation.elevation
-            ground_truth_elevation = (
-                self.ground_truth.for_file(metadata.filename.name).get("metadata", {}).get("reference_elevation")
+            # perform micro-average to store the metrics of all the boreholes in the document
+            metadata_metrics_list.borehole_metadata_metrics.append(
+                FileBoreholeMetadataMetrics(
+                    elevation_metrics=Metrics.micro_average(elevation_metrics_list),
+                    coordinates_metrics=Metrics.micro_average(coordinate_metrics_list),
+                    filename=file_data.filename,
+                )
             )
 
-            if extracted_elevation is not None and ground_truth_elevation is not None:
-                if math.isclose(extracted_elevation, ground_truth_elevation, abs_tol=0.1):
-                    elevation_metrics = Metrics(
-                        tp=1,
-                        fp=0,
-                        fn=0,
-                    )
-                else:
-                    elevation_metrics = Metrics(
-                        tp=0,
-                        fp=1,
-                        fn=1,
-                    )
+        return metadata_metrics_list
+
+    def _evaluate_elevation(self, extracted_elevation: float | None, ground_truth_elevation: float | None):
+        """Private method used to evaluate the extracted elevation against the ground truth.
+
+        Args:
+            extracted_elevation (float | None): the extracted elevation
+            ground_truth_elevation (float | None): the groundtruth elevation
+
+        Returns:
+            Metrics: the metric for this elevation.
+        """
+        if extracted_elevation is not None and ground_truth_elevation is not None:
+            if math.isclose(extracted_elevation, ground_truth_elevation, abs_tol=0.1):
+                elevation_metrics = Metrics(
+                    tp=1,
+                    fp=0,
+                    fn=0,
+                )
             else:
                 elevation_metrics = Metrics(
                     tp=0,
-                    fp=1 if extracted_elevation is not None else 0,
-                    fn=1 if ground_truth_elevation is not None else 0,
+                    fp=1,
+                    fn=1,
                 )
+        else:
+            elevation_metrics = Metrics(
+                tp=0,
+                fp=1 if extracted_elevation is not None else 0,
+                fn=1 if ground_truth_elevation is not None else 0,
+            )
 
-            metadata_metrics_list.borehole_metadata_metrics.append(
-                FileBoreholeMetadataMetrics(
-                    elevation_metrics=elevation_metrics,
-                    coordinates_metrics=coordinate_metrics,
-                    filename=metadata.filename.name,
+        return elevation_metrics
+
+    def _evaluate_coordinate(self, extracted_coordinates: Coordinate | None, ground_truth_coordinates: dict | None):
+        """Private method used to evaluate the extracted coordinates against the ground truth.
+
+        Args:
+            extracted_coordinates (Coordinate | None): the extracted coordinates
+            ground_truth_coordinates (dict | None): the groundtruth coordinates
+
+        Returns:
+            Metrics: the metric for these coordinates.
+        """
+        if extracted_coordinates and ground_truth_coordinates:
+            if extracted_coordinates.east.coordinate_value > 2e6 and ground_truth_coordinates["E"] < 2e6:
+                ground_truth_east = int(ground_truth_coordinates["E"]) + 2e6
+                ground_truth_north = int(ground_truth_coordinates["N"]) + 1e6
+            elif extracted_coordinates.east.coordinate_value < 2e6 and ground_truth_coordinates["E"] > 2e6:
+                ground_truth_east = int(ground_truth_coordinates["E"]) - 2e6
+                ground_truth_north = int(ground_truth_coordinates["N"]) - 1e6
+            else:
+                ground_truth_east = int(ground_truth_coordinates["E"])
+                ground_truth_north = int(ground_truth_coordinates["N"])
+
+            if (math.isclose(int(extracted_coordinates.east.coordinate_value), ground_truth_east, abs_tol=2)) and (
+                math.isclose(int(extracted_coordinates.north.coordinate_value), ground_truth_north, abs_tol=2)
+            ):
+                coordinate_metrics = Metrics(
+                    tp=1,
+                    fp=0,
+                    fn=0,
                 )
+            else:
+                coordinate_metrics = Metrics(
+                    tp=0,
+                    fp=1,
+                    fn=1,
+                )
+        else:
+            coordinate_metrics = Metrics(
+                tp=0,
+                fp=1 if extracted_coordinates is not None else 0,
+                fn=1 if ground_truth_coordinates is not None else 0,
             )
 
-        return metadata_metrics_list
+        return coordinate_metrics
diff --git a/src/stratigraphy/evaluation/utility.py b/src/stratigraphy/evaluation/utility.py
index 6080e9b8..a9895c48 100644
--- a/src/stratigraphy/evaluation/utility.py
+++ b/src/stratigraphy/evaluation/utility.py
@@ -6,7 +6,7 @@
 from stratigraphy.layer.layer import LayerDepths
 
 
-def count_against_ground_truth(values: list[str], ground_truth: list[str]) -> Metrics:
+def count_against_ground_truth(values: list[any], ground_truth: list[any]) -> Metrics:
     """Count evaluation metrics by comparing predicted values against ground truth.
 
     Metrics are calculated as follows:
@@ -15,8 +15,8 @@ def count_against_ground_truth(values: list[str], ground_truth: list[str]) -> Me
     - False Negatives (fn): Number of missed ground truth values (len(ground_truth) - tp)
 
     Args:
-        values (list[str]): The predicted values to evaluate
-        ground_truth (list[str]): The ground truth values to compare against
+        values (list[any]): The predicted values to evaluate
+        ground_truth (list[any]): The ground truth values to compare against
 
     Returns:
         Metrics: Object containing tp, fp, and fn counts
diff --git a/src/stratigraphy/extract.py b/src/stratigraphy/extract.py
index 44093be4..687760c4 100644
--- a/src/stratigraphy/extract.py
+++ b/src/stratigraphy/extract.py
@@ -1,17 +1,15 @@
 """Contains the main extraction pipeline for stratigraphy."""
 
-from dataclasses import dataclass
-
 import fitz
 import rtree
 
 from stratigraphy.data_extractor.data_extractor import FeatureOnPage
 from stratigraphy.depth.interval import AAboveBInterval, Interval
-from stratigraphy.depths_materials_column_pairs.bounding_boxes import BoundingBoxes
+from stratigraphy.depths_materials_column_pairs.bounding_boxes import PageBoundingBoxes
 from stratigraphy.depths_materials_column_pairs.material_description_rect_with_sidebar import (
     MaterialDescriptionRectWithSidebar,
 )
-from stratigraphy.layer.layer import IntervalBlockPair, Layer, LayerDepths
+from stratigraphy.layer.layer import ExtractedBorehole, IntervalBlockPair, Layer, LayerDepths
 from stratigraphy.lines.line import TextLine
 from stratigraphy.sidebar.a_above_b_sidebar_extractor import AAboveBSidebarExtractor
 from stratigraphy.sidebar.a_to_b_sidebar_extractor import AToBSidebarExtractor
@@ -29,14 +27,6 @@
 )
 
 
-@dataclass
-class ProcessPageResult:
-    """The result of processing a single page of a pdf."""
-
-    predictions: list[Layer]
-    bounding_boxes: list[BoundingBoxes]
-
-
 class MaterialDescriptionRectWithSidebarExtractor:
     """Class with methods to extract pairs of a material description rect with a corresponding sidebar."""
 
@@ -58,7 +48,7 @@ def __init__(
         self.page_number = page_number
         self.params = params
 
-    def process_page(self) -> ProcessPageResult:
+    def process_page(self) -> list[ExtractedBorehole]:
         """Process a single page of a pdf.
 
         Finds all descriptions and depth intervals on the page and matches them.
@@ -98,18 +88,15 @@ def process_page(self) -> ProcessPageResult:
             item for index, item in enumerate(material_descriptions_sidebar_pairs) if index not in to_delete
         ]
 
-        bounding_boxes = [
-            BoundingBoxes.from_material_description_rect_with_sidebar(pair, self.page_number)
-            for pair in filtered_pairs
-        ]
+        return [self._create_borehole_from_pair(pair) for pair in filtered_pairs]
 
-        interval_block_pairs = [
-            interval_block_pair
-            for pair in filtered_pairs
-            for interval_block_pair in self._get_interval_block_pairs(pair)
-        ]
+    def _create_borehole_from_pair(self, pair: MaterialDescriptionRectWithSidebar) -> ExtractedBorehole:
+        bounding_boxes = PageBoundingBoxes.from_material_description_rect_with_sidebar(pair, self.page_number)
+
+        # this must not be flattened anymore, i.e. we keep the /per borehole separation
+        interval_block_pairs = self._get_interval_block_pairs(pair)
 
-        layer_predictions = [
+        borehole_layers = [
             Layer(
                 material_description=FeatureOnPage(
                     feature=MaterialDescription(
@@ -130,10 +117,19 @@ def process_page(self) -> ProcessPageResult:
             )
             for pair in interval_block_pairs
         ]
-        layer_predictions = [layer for layer in layer_predictions if layer.description_nonempty()]
-        return ProcessPageResult(layer_predictions, bounding_boxes)
+
+        borehole_layers = [layer for layer in borehole_layers if layer.description_nonempty()]
+        return ExtractedBorehole(borehole_layers, [bounding_boxes])  # takes a list of bounding boxes
 
     def _get_interval_block_pairs(self, pair: MaterialDescriptionRectWithSidebar) -> list[IntervalBlockPair]:
+        """Get the interval block pairs for a given material description rect with sidebar.
+
+        Args:
+            pair (MaterialDescriptionRectWithSidebar): The material description rect with sidebar.
+
+        Returns:
+            list[IntervalBlockPair]: The interval block pairs.
+        """
         description_lines = get_description_lines(self.lines, pair.material_description_rect)
         if len(description_lines) > 1:
             if pair.sidebar:
diff --git a/src/stratigraphy/groundwater/groundwater_extraction.py b/src/stratigraphy/groundwater/groundwater_extraction.py
index 784a8224..3469b344 100644
--- a/src/stratigraphy/groundwater/groundwater_extraction.py
+++ b/src/stratigraphy/groundwater/groundwater_extraction.py
@@ -49,7 +49,7 @@ def __str__(self) -> str:
         Returns:
             str: The object as a string.
         """
-        return f"Groundwater(" f"date={self.format_date()}, " f"depth={self.depth}, " f"elevation={self.elevation})"
+        return f"Groundwater(date={self.format_date()}, depth={self.depth}, elevation={self.elevation})"
 
     @staticmethod
     def from_json_values(depth: float | None, date: str | None, elevation: float | None) -> "Groundwater":
@@ -106,13 +106,71 @@ def to_json(self) -> dict:
         }
 
 
+@dataclass
+class GroundwatersInBorehole:
+    """Class for extracted groundwater information from a single borehole."""
+
+    groundwater_feature_list: list[FeatureOnPage[Groundwater]]
+
+    def to_json(self) -> list[dict]:
+        """Converts the object to a list of dictionaries.
+
+        Returns:
+            list[dict]: The object as a list of dictionaries.
+        """
+        return [entry.to_json() for entry in self.groundwater_feature_list]
+
+    @classmethod
+    def from_json(cls, json_object: list[dict]) -> "GroundwatersInBorehole":
+        """Extract a GroundwatersInBorehole object from a json dictionary.
+
+        Args:
+            json_object (list[dict]): the json object containing the informations of the borehole
+
+        Returns:
+            GroundwatersInBorehole: the GroundwatersInBorehole object
+        """
+        return cls([FeatureOnPage.from_json(gw_data, Groundwater) for gw_data in json_object])
+
+
 @dataclass
 class GroundwaterInDocument:
     """Class for extracted groundwater information from a document."""
 
-    groundwater: list[FeatureOnPage[Groundwater]]
+    borehole_groundwaters: list[GroundwatersInBorehole]
     filename: str
 
+    def to_json(self) -> list[dict]:
+        """Converts the object to a list of dictionaries.
+
+        Returns:
+            list[dict]: The object as a list of dictionaries.
+        """
+        return [borehole_gw.to_json() for borehole_gw in self.borehole_groundwaters]
+
+
+class GroundwaterLevelExtractor(DataExtractor):
+    """Extract groundwater informations from a PDF document."""
+
+    feature_name = "groundwater"
+
+    is_searching_groundwater_illustration: bool = False
+
+    # look for elevation values to the left, right and/or immediately below the key
+    search_left_factor: float = 2
+    search_right_factor: float = 8
+    search_below_factor: float = 2
+    search_above_factor: float = 0
+
+    preprocess_replacements = {",": ".", "'": ".", "o": "0", "\n": " ", "ü": "u"}
+
+    def __init__(self):
+        super().__init__()
+
+        self.is_searching_groundwater_illustration = os.getenv("IS_SEARCHING_GROUNDWATER_ILLUSTRATION") == "True"
+        if self.is_searching_groundwater_illustration:
+            logger.info("Searching for groundwater information in illustrations.")
+
     @classmethod
     def near_material_description(
         cls,
@@ -120,7 +178,7 @@ def near_material_description(
         page_number: int,
         lines: list[TextLine],
         material_description_bbox: BoundingBox,
-        terrain_elevation: Elevation | None = None,
+        terrain_elevation: FeatureOnPage[Elevation] | None,
     ) -> list[FeatureOnPage[Groundwater]]:
         """Extracts groundwater information from a near material description bounding box on a page.
 
@@ -129,7 +187,8 @@ def near_material_description(
             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 (Elevation | None): The elevation of the terrain.
+            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.
@@ -152,37 +211,6 @@ def near_material_description(
             terrain_elevation=terrain_elevation,
         )
 
-    def to_json(self) -> list[dict]:
-        """Converts the object to a list of dictionaries.
-
-        Returns:
-            list[dict]: The object as a list of dictionaries.
-        """
-        return [entry.to_json() for entry in self.groundwater]
-
-
-class GroundwaterLevelExtractor(DataExtractor):
-    """Extracts coordinates from a PDF document."""
-
-    feature_name = "groundwater"
-
-    is_searching_groundwater_illustration: bool = False
-
-    # look for elevation values to the left, right and/or immediately below the key
-    search_left_factor: float = 2
-    search_right_factor: float = 8
-    search_below_factor: float = 2
-    search_above_factor: float = 0
-
-    preprocess_replacements = {",": ".", "'": ".", "o": "0", "\n": " ", "ü": "u"}
-
-    def __init__(self):
-        super().__init__()
-
-        self.is_searching_groundwater_illustration = os.getenv("IS_SEARCHING_GROUNDWATER_ILLUSTRATION") == "True"
-        if self.is_searching_groundwater_illustration:
-            logger.info("Searching for groundwater information in illustrations.")
-
     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.
 
@@ -314,7 +342,11 @@ def get_groundwater_info_from_lines(self, lines: list[TextLine], page: int) -> F
             logger.warning("Could not extract groundwater depth nor elevation from the lines near the key.")
 
     def extract_groundwater(
-        self, page_number: int, lines: list[TextLine], document: fitz.Document, terrain_elevation: Elevation | None
+        self,
+        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.
 
@@ -327,7 +359,8 @@ 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 (Elevation | None): The elevation of the borehole.
+            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)
@@ -342,17 +375,17 @@ def extract_groundwater(
             found_groundwater, confidence_list = get_groundwater_from_illustration(
                 self, lines, page_number, document, terrain_elevation
             )
+
             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:
-            # If the elevation is provided, calculate the depth of the groundwater
             for entry in found_groundwater:
                 if not entry.feature.depth and entry.feature.elevation:
-                    entry.feature.depth = round(terrain_elevation.elevation - entry.feature.elevation, 2)
+                    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.elevation - entry.feature.depth, 2)
+                    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])
diff --git a/src/stratigraphy/layer/duplicate_detection.py b/src/stratigraphy/layer/duplicate_detection.py
index 76ea58da..098e4656 100644
--- a/src/stratigraphy/layer/duplicate_detection.py
+++ b/src/stratigraphy/layer/duplicate_detection.py
@@ -7,7 +7,7 @@
 import Levenshtein
 import numpy as np
 from stratigraphy.annotations.plot_utils import convert_page_to_opencv_img
-from stratigraphy.layer.layer import Layer, LayersInDocument
+from stratigraphy.layer.layer import ExtractedBorehole, Layer, LayersInDocument
 
 logger = logging.getLogger(__name__)
 
@@ -15,10 +15,10 @@
 def remove_duplicate_layers(
     previous_page: fitz.Page,
     current_page: fitz.Page,
-    previous_layers: LayersInDocument,
-    current_layers: list[Layer],
+    previous_layers_with_bb: LayersInDocument,
+    current_layers_with_bb: LayersInDocument,
     img_template_probability_threshold: float,
-) -> list[Layer]:
+) -> list[ExtractedBorehole]:
     """Remove duplicate layers from the current page based on the layers of the previous page.
 
     We check if a layer on the current page is present on the previous page. If we have 3 consecutive layers that are
@@ -31,77 +31,95 @@ def remove_duplicate_layers(
     Args:
         previous_page (fitz.Page): The previous page.
         current_page (fitz.Page): The current page containing the layers to check for duplicates.
-        previous_layers (LayersInDocument): The layers of the previous page.
-        current_layers (list[Layer]): The layers of the current page.
+        previous_layers_with_bb (LayersInDocument): The layers of the previous page, with their bounding box.
+        current_layers_with_bb (LayersInDocument): The layers of the current page, with their bounding box.
         img_template_probability_threshold (float): The threshold for the template matching probability
 
     Returns:
-        list[Layer]: The layers of the current page without duplicates.
+        list[ExtractedBorehole]: The layers of the boreholes on the current page without duplicates. Their bounding
+            boxes are kept the same.
     """
-    sorted_layers = sorted(current_layers, key=lambda x: x.material_description.rect.y0)
-    first_non_duplicated_layer_index = 0
-    count_consecutive_non_duplicate_layers = 0
-    for layer_index, layer in enumerate(sorted_layers):
-        if (
-            count_consecutive_non_duplicate_layers >= 3
-        ):  # if we have three consecutive non-duplicate layers, we can assume that there is no further page overlap.
-            break
-
-        # check if current layer has an overlapping layer on the previous page.
-        # for that purpose compare depth interval as well as material description text.
-        duplicate_condition = False
-        if layer.depths is None:
-            duplicate_condition = check_duplicate_layer_by_template_matching(
-                previous_page, current_page, layer, img_template_probability_threshold
-            )
-        else:  # in this case we compare the depth interval and material description
-            current_material_description = layer.material_description
-            current_depth_interval = layer.depths
-            for previous_layer in previous_layers.layers:
-                if previous_layer.depths is None:
-                    # It may happen, that a layer on the previous page does not have depth interval assigned.
-                    # In this case we skip the comparison. This should only happen in some edge cases, as we
-                    # assume that when the current page has a depth column, that the previous page also contains a
-                    # depth column. We assume overlapping pages and a depth column should extend over both pages.
-                    continue
-
-                previous_material_description = previous_layer.material_description
-                previous_depth_interval = previous_layer.depths
-
-                # start values for the depth intervals may be None. End values are always explicitly set.
-                current_depth_interval_start = (
-                    current_depth_interval.start.value
-                    if ((current_depth_interval is not None) and (current_depth_interval.start is not None))
-                    else None
-                )
-                previous_depth_interval_start = (
-                    previous_depth_interval.start.value
-                    if ((previous_depth_interval is not None) and (previous_depth_interval.start is not None))
-                    else None
+    non_duplicated_extracted_boreholes: list[ExtractedBorehole] = []
+    # iterate on all the borehole profiles identified on this page
+    for current_borehole_layers_with_bb in current_layers_with_bb.boreholes_layers_with_bb:
+        current_borehole_layers = current_borehole_layers_with_bb.predictions
+        sorted_layers = sorted(current_borehole_layers, key=lambda x: x.material_description.rect.y0)
+        first_non_duplicated_layer_index = 0
+        count_consecutive_non_duplicate_layers = 0
+        for layer_index, layer in enumerate(sorted_layers):
+            # if we have 3 consecutive non-duplicate layers, we can assume that there is no further page overlap.
+            if count_consecutive_non_duplicate_layers >= 3:
+                break
+
+            # check if current layer has an overlapping layer on the previous page.
+            # for that purpose compare depth interval as well as material description text.
+            duplicate_condition = False
+            if layer.depths is None:
+                duplicate_condition = check_duplicate_layer_by_template_matching(
+                    previous_page, current_page, layer, img_template_probability_threshold
                 )
-                # check if material description is the same
-                text_similarity = (
-                    Levenshtein.ratio(
-                        current_material_description.feature.text, previous_material_description.feature.text
-                    )
-                    > 0.9
-                )
-
-                same_start_depth = current_depth_interval_start == previous_depth_interval_start
-                if current_depth_interval.end and previous_depth_interval.end:
-                    same_end_depth = current_depth_interval.end.value == previous_depth_interval.end.value
-
-                    if text_similarity and same_start_depth and same_end_depth:
-                        duplicate_condition = True
-                        logger.info("Removing duplicate layer.")
+            else:  # in this case we compare the depth interval and material description
+                current_material_description = layer.material_description
+                current_depth_interval = layer.depths
+                # iterate on all the layers in the previously identified borehole profiles
+                for previous_borehole_layers_with_bb in previous_layers_with_bb.boreholes_layers_with_bb:
+                    previous_borehole_layers = previous_borehole_layers_with_bb.predictions
+                    for previous_layer in previous_borehole_layers:
+                        if previous_layer.depths is None:
+                            # It may happen, that a layer on the previous page does not have depth interval assigned.
+                            # In this case we skip the comparison. This should only happen in some edge cases, as we
+                            # assume that when the current page has a depth column, that the previous page also
+                            # contains a depth column.
+                            # We assume overlapping pages and a depth column should extend over both pages.
+                            continue
+
+                        previous_material_description = previous_layer.material_description
+                        previous_depth_interval = previous_layer.depths
+
+                        # start values for the depth intervals may be None. End values are always explicitly set.
+                        current_depth_interval_start = (
+                            current_depth_interval.start.value
+                            if ((current_depth_interval is not None) and (current_depth_interval.start is not None))
+                            else None
+                        )
+                        previous_depth_interval_start = (
+                            previous_depth_interval.start.value
+                            if ((previous_depth_interval is not None) and (previous_depth_interval.start is not None))
+                            else None
+                        )
+                        # check if material description is the same
+                        text_similarity = (
+                            Levenshtein.ratio(
+                                current_material_description.feature.text, previous_material_description.feature.text
+                            )
+                            > 0.9
+                        )
+
+                        same_start_depth = current_depth_interval_start == previous_depth_interval_start
+                        if current_depth_interval.end and previous_depth_interval.end:
+                            same_end_depth = current_depth_interval.end.value == previous_depth_interval.end.value
+
+                            if text_similarity and same_start_depth and same_end_depth:
+                                duplicate_condition = True
+                                logger.info("Removing duplicate layer.")
+                                break
+
+                    if duplicate_condition:
                         break
+            # the layers of the current borehole are duplicates of the layers of one of the previous borehole
+            if duplicate_condition:
+                first_non_duplicated_layer_index = layer_index + 1  # all layers before this layer are duplicates
+                count_consecutive_non_duplicate_layers = 0
+            else:
+                count_consecutive_non_duplicate_layers += 1
+        non_duplicated_extracted_boreholes.append(
+            ExtractedBorehole(
+                predictions=sorted_layers[first_non_duplicated_layer_index:],
+                bounding_boxes=current_borehole_layers_with_bb.bounding_boxes,
+            )
+        )
 
-        if duplicate_condition:
-            first_non_duplicated_layer_index = layer_index + 1  # all layers before this layer are duplicates
-            count_consecutive_non_duplicate_layers = 0
-        else:
-            count_consecutive_non_duplicate_layers += 1
-    return sorted_layers[first_non_duplicated_layer_index:]
+    return non_duplicated_extracted_boreholes
 
 
 def check_duplicate_layer_by_template_matching(
diff --git a/src/stratigraphy/layer/layer.py b/src/stratigraphy/layer/layer.py
index f831d495..c57dbf6a 100644
--- a/src/stratigraphy/layer/layer.py
+++ b/src/stratigraphy/layer/layer.py
@@ -5,6 +5,7 @@
 import fitz
 from stratigraphy.data_extractor.data_extractor import ExtractedFeature, FeatureOnPage
 from stratigraphy.depth.interval import Interval
+from stratigraphy.depths_materials_column_pairs.bounding_boxes import PageBoundingBoxes
 from stratigraphy.text.textblock import MaterialDescription, TextBlock
 from stratigraphy.util.util import parse_text
 
@@ -140,11 +141,69 @@ def from_json(cls, data: dict) -> "Layer":
 
 
 @dataclass
-class LayersInDocument:
-    """A class to represent predictions for a single document."""
+class LayersInBorehole:
+    """Represent the data for all layers in a borehole profile."""
 
     layers: list[Layer]
-    filename: str
+
+    def to_json(self):
+        """Converts the object to a dictionary.
+
+        Returns:
+            dict: The object as a dictionary.
+        """
+        return [layer.to_json() for layer in self.layers]
+
+    @classmethod
+    def from_json(cls, json_object) -> "LayersInBorehole":
+        """Extract a LayersInBorehole object from a json dictionary.
+
+        Args:
+            json_object (dict): The object as a dictionary.
+
+        Returns:
+            LayersInBorehole: The LayersInBorehole object.
+        """
+        return cls([Layer.from_json(layer_data) for layer_data in json_object])
+
+
+@dataclass
+class ExtractedBorehole:
+    """A class to store the extracted information of one single borehole."""
+
+    predictions: list[Layer]
+    bounding_boxes: list[PageBoundingBoxes]  # one for each page that the borehole spans
+
+
+class LayersInDocument:
+    """A class to represent predictions for a single document.
+
+    It contains a list of LayersInBorehole, not just a list of Layer.
+    """
+
+    def __init__(self, boreholes_layers: list[ExtractedBorehole], filename: str):
+        self.boreholes_layers_with_bb = boreholes_layers
+        self.filename = filename
+
+    def assign_layers_to_boreholes(self, layer_predictions: list[ExtractedBorehole]):
+        """SIMPLIFICATION: currently assumes that if there is more than one page, there is a single borehole.
+
+        This means that only pdfs with one page could contain more than one borehole.
+        This is the case for all example from zurich and geoquat,validation.
+        If we want to be more robust, a matching should be done to determine which layers goes with which borehole.
+
+        Args:
+            layer_predictions (list[ExtractedBorehole]): List containing the a list of all layers of all boreholes
+        """
+        if not layer_predictions:
+            return
+        if not self.boreholes_layers_with_bb:
+            # first page
+            self.boreholes_layers_with_bb = layer_predictions
+        else:
+            # second page, use assumption, also fot the bounding boxes
+            self.boreholes_layers_with_bb[0].bounding_boxes.extend(layer_predictions[0].bounding_boxes)
+            self.boreholes_layers_with_bb[0].predictions.extend(layer_predictions[0].predictions)
 
 
 @dataclass
diff --git a/src/stratigraphy/main.py b/src/stratigraphy/main.py
index e1b4cc9c..82b97f41 100644
--- a/src/stratigraphy/main.py
+++ b/src/stratigraphy/main.py
@@ -3,6 +3,7 @@
 import json
 import logging
 import os
+from collections import defaultdict
 from pathlib import Path
 
 import click
@@ -15,13 +16,19 @@
 from stratigraphy.annotations.plot_utils import plot_lines
 from stratigraphy.benchmark.score import evaluate
 from stratigraphy.extract import MaterialDescriptionRectWithSidebarExtractor
-from stratigraphy.groundwater.groundwater_extraction import GroundwaterInDocument
+from stratigraphy.groundwater.groundwater_extraction import (
+    GroundwaterInDocument,
+    GroundwaterLevelExtractor,
+    GroundwatersInBorehole,
+)
 from stratigraphy.layer.duplicate_detection import remove_duplicate_layers
 from stratigraphy.layer.layer import LayersInDocument
 from stratigraphy.lines.line_detection import extract_lines, line_detection_params
-from stratigraphy.metadata.metadata import BoreholeMetadata
+from stratigraphy.metadata.metadata import FileMetadata, MetadataInDocument
 from stratigraphy.text.extract_text import extract_text_lines
-from stratigraphy.util.predictions import FilePredictions, OverallFilePredictions
+from stratigraphy.util.file_predictions import FilePredictions
+from stratigraphy.util.overall_file_predictions import OverallFilePredictions
+from stratigraphy.util.predictions import BoreholeListBuilder, BoreholePredictions
 from stratigraphy.util.util import flatten, read_params
 
 load_dotenv()
@@ -225,92 +232,109 @@ def start_pipeline(
     predictions = OverallFilePredictions()
 
     for filename in tqdm(files, desc="Processing files", unit="file"):
-        if filename.endswith(".pdf"):
-            in_path = os.path.join(root, filename)
-            logger.info("Processing file: %s", in_path)
-
-            with fitz.Document(in_path) as doc:
-                # Extract metadata
-                metadata = BoreholeMetadata.from_document(doc)
-
-                # Save the predictions to the overall predictions object
-                # Initialize common variables
-                layers_in_document = LayersInDocument([], filename)
-                bounding_boxes = []
-                aggregated_groundwater_entries = []
-
-                if part == "all":
-                    # Extract the layers
-                    for page_index, page in enumerate(doc):
-                        page_number = page_index + 1
-                        logger.info("Processing page %s", page_number)
-
-                        text_lines = extract_text_lines(page)
-                        geometric_lines = extract_lines(page, line_detection_params)
-                        process_page_results = MaterialDescriptionRectWithSidebarExtractor(
-                            text_lines, geometric_lines, metadata.language, page_number, **matching_params
-                        ).process_page()
-
-                        # Extract the groundwater levels
-                        for page_bounding_box in process_page_results.bounding_boxes:
-                            material_description_bbox = page_bounding_box.material_description_bbox
-
-                            groundwater_entries_near_bbox = GroundwaterInDocument.near_material_description(
-                                document=doc,
-                                page_number=page_number,
-                                lines=text_lines,
-                                material_description_bbox=material_description_bbox,
-                                terrain_elevation=metadata.elevation,
-                            )
-                            ##avoid duplicate entries
-                            for groundwater_entry in groundwater_entries_near_bbox:
-                                if groundwater_entry not in aggregated_groundwater_entries:
-                                    aggregated_groundwater_entries.append(groundwater_entry)
-
-                        # TODO: Add remove duplicates here!
-                        if page_index > 0:
-                            layer_predictions = remove_duplicate_layers(
-                                previous_page=doc[page_index - 1],
-                                current_page=page,
-                                previous_layers=layers_in_document,
-                                current_layers=process_page_results.predictions,
-                                img_template_probability_threshold=matching_params[
-                                    "img_template_probability_threshold"
-                                ],
-                            )
+        if not filename.endswith(".pdf"):
+            logger.warning(f"{filename} does not end with .pdf and is not treated.")
+            continue
+
+        in_path = os.path.join(root, filename)
+        logger.info("Processing file: %s", in_path)
+
+        with fitz.Document(in_path) as doc:
+            # Extract metadata
+            file_metadata = FileMetadata.from_document(doc)
+            metadata = MetadataInDocument.from_document(doc)
+
+            # Save the predictions to the overall predictions object
+            # Initialize common variables
+            layers_with_bb_in_document = LayersInDocument([], filename)
+            aggregated_groundwater_entries = defaultdict(list)
+
+            if part != "all":
+                continue
+            # Extract the layers
+            for page_index, page in enumerate(doc):
+                page_number = page_index + 1
+                logger.info("Processing page %s", page_number)
+
+                text_lines = extract_text_lines(page)
+                geometric_lines = extract_lines(page, line_detection_params)
+                extracted_boreholes = MaterialDescriptionRectWithSidebarExtractor(
+                    text_lines, geometric_lines, file_metadata.language, page_number, **matching_params
+                ).process_page()
+                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:
-                            layer_predictions = process_page_results.predictions
-
-                        layers_in_document.layers.extend(layer_predictions)
-                        bounding_boxes.extend(process_page_results.bounding_boxes)
-
-                        if draw_lines:  # could be changed to if draw_lines and mlflow_tracking:
-                            if not mlflow_tracking:
-                                logger.warning(
-                                    "MLFlow tracking is not enabled. MLFLow is required to store the images."
-                                )
-                            else:
-                                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,
-                    groundwater=aggregated_groundwater_entries,
-                )
-
-                # Add file predictions
-                predictions.add_file_predictions(
-                    FilePredictions(
-                        file_name=filename,
-                        metadata=metadata,
-                        groundwater=groundwater_entries,
-                        layers_in_document=layers_in_document,
-                        bounding_boxes=bounding_boxes,
+                            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!
+                if page_index > 0:
+                    layer_with_bb_predictions = remove_duplicate_layers(
+                        previous_page=doc[page_index - 1],
+                        current_page=page,
+                        previous_layers_with_bb=layers_with_bb_in_document,
+                        current_layers_with_bb=processed_page_results,
+                        img_template_probability_threshold=matching_params["img_template_probability_threshold"],
                     )
-                )
+                else:
+                    layer_with_bb_predictions = processed_page_results.boreholes_layers_with_bb
+
+                layers_with_bb_in_document.assign_layers_to_boreholes(layer_with_bb_predictions)
+
+                if draw_lines:  # could be changed to if draw_lines and mlflow_tracking:
+                    if not mlflow_tracking:
+                        logger.warning("MLFlow tracking is not enabled. MLFLow is required to store the images.")
+                    else:
+                        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,
+                elevations_list=metadata.elevations,
+                coordinates_list=metadata.coordinates,
+            ).build()
+
+            # Add file predictions
+            predictions.add_file_predictions(FilePredictions(borehole_predictions_list, file_metadata, filename))
 
     logger.info("Metadata written to %s", metadata_path)
     with open(metadata_path, "w", encoding="utf8") as file:
diff --git a/src/stratigraphy/metadata/coordinate_extraction.py b/src/stratigraphy/metadata/coordinate_extraction.py
index 2184aa92..ce1e6f22 100644
--- a/src/stratigraphy/metadata/coordinate_extraction.py
+++ b/src/stratigraphy/metadata/coordinate_extraction.py
@@ -7,7 +7,7 @@
 
 import fitz
 import regex
-from stratigraphy.data_extractor.data_extractor import DataExtractor, ExtractedFeature
+from stratigraphy.data_extractor.data_extractor import DataExtractor, ExtractedFeature, FeatureOnPage
 from stratigraphy.lines.line import TextLine
 from stratigraphy.text.extract_text import extract_text_lines
 
@@ -36,10 +36,6 @@ class Coordinate(ExtractedFeature):
     east: CoordinateEntry
     north: CoordinateEntry
 
-    # TODO remove after refactoring to use FeatureOnPage also for coordinates
-    rect: fitz.Rect  # The rectangle that contains the extracted information
-    page: int  # The page number of the PDF document
-
     def __post_init__(self):
         # east always greater than north by definition. Irrespective of the leading 1 or 2
         if self.east.coordinate_value < self.north.coordinate_value:
@@ -55,39 +51,26 @@ def to_json(self) -> dict:
         Returns:
             dict: The object as a dictionary.
         """
-        return {
-            "E": self.east.coordinate_value,
-            "N": self.north.coordinate_value,
-            "rect": [self.rect.x0, self.rect.y0, self.rect.x1, self.rect.y1],
-            "page": self.page,
-        }
+        return {"E": self.east.coordinate_value, "N": self.north.coordinate_value}
 
     @staticmethod
-    def from_values(east: float, north: float, rect: fitz.Rect, page: int) -> Coordinate | None:
+    def from_values(east: float, north: float) -> Coordinate | None:
         """Creates a Coordinate object from the given values.
 
         Args:
             east (float): The east coordinate value.
             north (float): The north coordinate value.
-            rect (fitz.Rect): The rectangle that contains the extracted information.
-            page (int): The page number of the PDF document.
 
         Returns:
             Coordinate | None: The coordinate object.
         """
         if 1e6 < east < 1e7:
             return LV95Coordinate(
-                east=CoordinateEntry(coordinate_value=east),
-                north=CoordinateEntry(coordinate_value=north),
-                rect=rect,
-                page=page,
+                east=CoordinateEntry(coordinate_value=east), north=CoordinateEntry(coordinate_value=north)
             )
         elif east < 1e6:
             return LV03Coordinate(
-                east=CoordinateEntry(coordinate_value=east),
-                north=CoordinateEntry(coordinate_value=north),
-                rect=rect,
-                page=page,
+                east=CoordinateEntry(coordinate_value=east), north=CoordinateEntry(coordinate_value=north)
             )
         else:
             logger.warning("Invalid coordinates format. Got E: %s, N: %s", east, north)
@@ -103,9 +86,7 @@ def from_json(cls, input: dict) -> Coordinate:
         Returns:
             Coordinate: The coordinate object.
         """
-        return Coordinate.from_values(
-            east=input["E"], north=input["N"], rect=fitz.Rect(input["rect"]), page=input["page"]
-        )
+        return Coordinate.from_values(east=input["E"], north=input["N"])
 
 
 @dataclass
@@ -142,7 +123,7 @@ class CoordinateExtractor(DataExtractor):
 
     preprocess_replacements = {",": ".", "'": ".", "o": "0", "\n": " "}
 
-    def get_coordinates_with_x_y_labels(self, lines: list[TextLine], page: int) -> list[Coordinate]:
+    def get_coordinates_with_x_y_labels(self, lines: list[TextLine], page: int) -> list[FeatureOnPage[Coordinate]]:
         """Find coordinates with explicit "X" and "Y" labels from the text lines.
 
         Args:
@@ -150,7 +131,7 @@ def get_coordinates_with_x_y_labels(self, lines: list[TextLine], page: int) -> l
             page (int): the page number (1-based) of the PDF document
 
         Returns:
-            list[Coordinate]: all found coordinates
+            list[FeatureOnPage[Coordinate]]: all found coordinates
         """
         # In this case, we can allow for some whitespace in between the numbers.
         # In some older borehole profile the OCR may recognize whitespace between two digits.
@@ -170,16 +151,13 @@ def get_coordinates_with_x_y_labels(self, lines: list[TextLine], page: int) -> l
             rect.include_rect(x_match[1])
             rect.include_rect(y_match[1])
             coordinates = Coordinate.from_values(
-                east=int("".join(x_match[0].groups(default=""))),
-                north=int("".join(y_match[0].groups(default=""))),
-                rect=rect,
-                page=page,
+                east=int("".join(x_match[0].groups(default=""))), north=int("".join(y_match[0].groups(default="")))
             )
             if coordinates is not None and coordinates.is_valid():
-                found_coordinates.append(coordinates)
+                found_coordinates.append(FeatureOnPage(feature=coordinates, rect=rect, page=page))
         return found_coordinates
 
-    def get_coordinates_near_key(self, lines: list[TextLine], page: int) -> list[Coordinate]:
+    def get_coordinates_near_key(self, lines: list[TextLine], page: int) -> list[FeatureOnPage[Coordinate]]:
         """Find coordinates from text lines that are close to an explicit "coordinates" label.
 
         Also apply some preprocessing to the text of those text lines, to deal with some common (OCR) errors.
@@ -189,7 +167,7 @@ def get_coordinates_near_key(self, lines: list[TextLine], page: int) -> list[Coo
             page (int): the page number (1-based) of the PDF document
 
         Returns:
-            list[Coordinate]: all found coordinates
+            list[FeatureOnPage[Coordinate]]: all found coordinates
         """
         # find the key that indicates the coordinate information
         coordinate_key_lines = self.find_feature_key(lines)
@@ -201,7 +179,7 @@ def get_coordinates_near_key(self, lines: list[TextLine], page: int) -> list[Coo
 
         return extracted_coordinates
 
-    def get_coordinates_from_lines(self, lines: list[TextLine], page: int) -> list[Coordinate]:
+    def get_coordinates_from_lines(self, lines: list[TextLine], page: int) -> list[FeatureOnPage[Coordinate]]:
         r"""Matches the coordinates in a string of text.
 
         The query searches for a pair of coordinates of 6 or 7 digits, respectively. The pair of coordinates
@@ -233,7 +211,7 @@ def get_coordinates_from_lines(self, lines: list[TextLine], page: int) -> list[C
             page (int): the page number (1-based) of the PDF document
 
         Returns:
-            list[Coordinate]: A list of potential coordinates
+            list[FeatureOnPage[Coordinate]]: A list of potential coordinates
         """
         full_regex = regex.compile(
             r"(?:[XY][=:\s]{0,2})?"
@@ -243,16 +221,20 @@ def get_coordinates_from_lines(self, lines: list[TextLine], page: int) -> list[C
             + r"\b"
         )
         potential_coordinates = [
-            Coordinate.from_values(
-                east=float("{}.{}".format("".join(match.groups(default="")[:3]), match.groups(default="")[3])),
-                north=float("{}.{}".format("".join(match.groups(default="")[4:-1]), match.groups(default="")[-1])),
-                rect=rect,
+            FeatureOnPage(
+                feature=Coordinate.from_values(
+                    east=float("{}.{}".format("".join(match.groups(default="")[:3]), match.groups(default="")[3])),
+                    north=float("{}.{}".format("".join(match.groups(default="")[4:-1]), match.groups(default="")[-1])),
+                ),
                 page=page,
+                rect=rect,
             )
             for match, rect in CoordinateExtractor._match_text_with_rect(lines, full_regex, self.preprocess)
         ]
         return [
-            coordinates for coordinates in potential_coordinates if coordinates is not None and coordinates.is_valid()
+            coordinates
+            for coordinates in potential_coordinates
+            if coordinates.feature is not None and coordinates.feature.is_valid()
         ]
 
     @staticmethod
@@ -282,7 +264,9 @@ def _match_text_with_rect(
             results.append((match, rect))
         return results
 
-    def extract_coordinates_aggregated(self, text_lines: list[TextLine], page_number: int) -> Coordinate | None:
+    def extract_coordinates_aggregated(
+        self, text_lines: list[TextLine], page_number: int
+    ) -> list[FeatureOnPage[Coordinate]]:
         """Extracts the coordinates from a borehole profile.
 
         Processes the borehole profile page by page and tries to find the coordinates in the respective text of the
@@ -294,7 +278,7 @@ def extract_coordinates_aggregated(self, text_lines: list[TextLine], page_number
             3. if that gives no results either, try to detect coordinates in the full text
 
         Returns:
-            Coordinate | None: the extracted coordinates (if any)
+            list[FeatureOnPage[Coordinate]]: the extracted coordinates (if any)
         """
         found_coordinates = (
             self.get_coordinates_with_x_y_labels(text_lines, page_number)
@@ -302,12 +286,9 @@ def extract_coordinates_aggregated(self, text_lines: list[TextLine], page_number
             or self.get_coordinates_from_lines(text_lines, page_number)
         )
 
-        if len(found_coordinates) > 0:
-            return found_coordinates[0]
-
-        logger.info("No coordinates found in this borehole profile.")
+        return found_coordinates
 
-    def extract_coordinates(self, document: fitz.Document) -> Coordinate | None:
+    def extract_coordinates(self, document: fitz.Document) -> list[FeatureOnPage[Coordinate]]:
         """Extracts the coordinates from a borehole profile.
 
         Processes the borehole profile page by page and tries to find the coordinates in the respective text of the
@@ -322,9 +303,14 @@ def extract_coordinates(self, document: fitz.Document) -> Coordinate | None:
             document (fitz.Document): document from which coordinates are extracted page by page
 
         Returns:
-            Coordinate | None: the extracted coordinates (if any)
+            list[FeatureOnPage[Coordinate]]: the extracted coordinates (if any)
         """
+        coordinates: list[FeatureOnPage[Coordinate]] = []
         for page in document:
             text_lines = extract_text_lines(page)
             page_number = page.number + 1  # page.number is 0-based
-            return self.extract_coordinates_aggregated(text_lines, page_number)
+
+            coord = self.extract_coordinates_aggregated(text_lines, page_number)
+            coordinates.extend(coord)
+
+        return coordinates
diff --git a/src/stratigraphy/metadata/elevation_extraction.py b/src/stratigraphy/metadata/elevation_extraction.py
index 494cc99d..fc56b842 100644
--- a/src/stratigraphy/metadata/elevation_extraction.py
+++ b/src/stratigraphy/metadata/elevation_extraction.py
@@ -11,7 +11,7 @@
 
 import fitz
 import numpy as np
-from stratigraphy.data_extractor.data_extractor import DataExtractor, ExtractedFeature
+from stratigraphy.data_extractor.data_extractor import DataExtractor, ExtractedFeature, FeatureOnPage
 from stratigraphy.groundwater.utility import extract_elevation
 from stratigraphy.lines.line import TextLine
 from stratigraphy.text.extract_text import extract_text_lines_from_bbox
@@ -25,18 +25,10 @@ class Elevation(ExtractedFeature):
 
     elevation: float  # Elevation relative to the mean sea level
 
-    # TODO remove after refactoring to use FeatureOnPage also for elevation
-    rect: fitz.Rect  # The rectangle that contains the extracted information
-    page: int  # The page number of the PDF document
-
     def __post_init__(self):
         """Checks if the information is valid."""
         if not isinstance(self.elevation, float):
             raise ValueError("Elevation must be a float")
-        if not isinstance(self.page, int):
-            raise ValueError("Page must be an integer")
-        if not isinstance(self.rect, fitz.Rect):
-            raise ValueError("Rect must be a fitz.Rect")
 
     def is_valid(self) -> bool:
         """Checks if the information is valid.
@@ -52,7 +44,7 @@ def __str__(self) -> str:
         Returns:
             str: The object as a string.
         """
-        return f"Elevation(" f"elevation={self.elevation}, " f"page={self.page})"
+        return f"Elevation(elevation={self.elevation})"
 
     def to_json(self) -> dict:
         """Converts the object to a dictionary.
@@ -60,11 +52,7 @@ def to_json(self) -> dict:
         Returns:
             dict: The object as a dictionary.
         """
-        return {
-            "elevation": self.elevation,
-            "page": self.page if self.page else None,
-            "rect": [self.rect.x0, self.rect.y0, self.rect.x1, self.rect.y1] if self.rect else None,
-        }
+        return {"elevation": self.elevation}
 
     @classmethod
     def from_json(cls, data: dict) -> "Elevation":
@@ -76,15 +64,7 @@ def from_json(cls, data: dict) -> "Elevation":
         Returns:
             Elevation: The elevation information object.
         """
-        elevation = data["elevation"]
-        page = data["page"]
-        rect = data["rect"]
-
-        # Convert to fitz.Rect
-        if rect:
-            rect = fitz.Rect(rect[0], rect[1], rect[2], rect[3])
-
-        return cls(elevation=elevation, page=page, rect=rect)
+        return cls(elevation=data["elevation"])
 
 
 class ElevationExtractor(DataExtractor):
@@ -107,7 +87,7 @@ class ElevationExtractor(DataExtractor):
 
     preprocess_replacements = {",": ".", "'": ".", "o": "0", "\n": " ", "ate": "ote"}
 
-    def get_elevation_near_key(self, lines: list[TextLine], page: int) -> Elevation | None:
+    def get_elevation_near_key(self, lines: list[TextLine], page: int) -> list[FeatureOnPage[Elevation]]:
         """Find elevation from text lines that are close to an explicit "elevation" label.
 
         Also apply some preprocessing to the text of those text lines, to deal with some common (OCR) errors.
@@ -115,10 +95,9 @@ def get_elevation_near_key(self, lines: list[TextLine], page: int) -> Elevation
         Args:
             lines (list[TextLine]): all the lines of text to search in
             page (int): the page number (1-based) of the PDF document
-            page_width (float): the width of the current page (in points / PyMuPDF coordinates)
 
         Returns:
-            Elevation | None: the found elevation
+            list[FeatureOnPage[Elevation]]: the found elevation
         """
         # find the key that indicates the elevation information
         elevation_key_lines = self.find_feature_key(lines)
@@ -129,7 +108,7 @@ def get_elevation_near_key(self, lines: list[TextLine], page: int) -> Elevation
 
             try:
                 extracted_elevation = self.get_elevation_from_lines(elevation_lines, page)
-                if extracted_elevation.elevation:
+                if extracted_elevation.feature.elevation:
                     extracted_elevation_list.append(extracted_elevation)
             except ValueError as error:
                 logger.warning("ValueError: %s", error)
@@ -137,22 +116,30 @@ def get_elevation_near_key(self, lines: list[TextLine], page: int) -> Elevation
 
         return self.select_best_elevation_information(extracted_elevation_list)
 
-    def select_best_elevation_information(self, extracted_elevation_list: list[Elevation]) -> Elevation | None:
-        """Select the best elevation information from a list of extracted elevation information.
+    def select_best_elevation_information(
+        self, extracted_elevation_list: list[FeatureOnPage[Elevation]]
+    ) -> list[FeatureOnPage[Elevation]]:
+        """Select the best elevations information from a list of extracted elevation information.
+
+        Currently returns all of the elevations found, sorted with the highest first.
 
         Args:
-            extracted_elevation_list (list[Elevation]): A list of extracted elevation information.
+            extracted_elevation_list (list[FeatureOnPage[Elevation]]): A list of extracted elevation information.
 
         Returns:
-            Elevation | None: The best extracted elevation information.
+            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.elevation, reverse=True)
+        extracted_elevation_list.sort(key=lambda x: x.feature.elevation, reverse=True)
 
-        # Return the first element of the sorted list
-        return extracted_elevation_list[0] if extracted_elevation_list else None
+        # Return all elements of the sorted list
+        return extracted_elevation_list
 
-    def get_elevation_from_lines(self, lines: list[TextLine], page: int) -> Elevation:
+    def get_elevation_from_lines(self, lines: list[TextLine], page: int) -> FeatureOnPage[Elevation]:
         r"""Matches the elevation in a string of text.
 
         Args:
@@ -192,13 +179,13 @@ def get_elevation_from_lines(self, lines: list[TextLine], page: int) -> Elevatio
             rect_union = None
 
         if elevation:
-            return Elevation(elevation=elevation, rect=rect_union, page=page)
+            return FeatureOnPage(feature=Elevation(elevation=elevation), rect=rect_union, page=page)
         else:
             raise ValueError("Could not extract all required information from the lines provided.")
 
     def extract_elevation_from_bbox(
         self, pdf_page: fitz.Page, page_number: int, bbox: fitz.Rect | None = None
-    ) -> Elevation | None:
+    ) -> list[FeatureOnPage[Elevation]]:
         """Extract the elevation information from a bounding box.
 
         Args:
@@ -207,19 +194,23 @@ def extract_elevation_from_bbox(
             page_number (int): The page number.
 
         Returns:
-            Elevation | None: The extracted elevation information.
+            list[FeatureOnPage[Elevation]]: The extracted elevation information.
         """
         lines = extract_text_lines_from_bbox(pdf_page, bbox)
 
-        found_elevation_value = self.get_elevation_near_key(lines, page_number)
+        found_elevation_values = self.get_elevation_near_key(lines, page_number)
 
-        if found_elevation_value:
-            logger.info("Found elevation in the bounding box: %s", found_elevation_value.elevation)
-            return found_elevation_value
+        if found_elevation_values:
+            logger.info(
+                "Found elevations in the bounding box: %s",
+                [value.feature.elevation for value in found_elevation_values],
+            )
+        else:
+            logger.info("No elevation found in the bounding box.")
 
-        logger.info("No elevation found in the bounding box.")
+        return found_elevation_values
 
-    def extract_elevation(self, document: fitz.Document) -> Elevation | None:
+    def extract_elevation(self, document: fitz.Document) -> list[FeatureOnPage[Elevation]]:
         """Extracts the elevation information from a borehole profile.
 
         Processes the borehole profile page by page and tries to find the feature key in the respective text of the
@@ -229,10 +220,11 @@ def extract_elevation(self, document: fitz.Document) -> Elevation | None:
             document (fitz.Document): document from which elevation is extracted page by page
 
         Returns:
-            Elevation | None: The extracted elevation information.
+            list[FeatureOnPage[Elevation]]: The extracted elevation information.
         """
+        elevations: list[Elevation] = []
         for page in document:
             page_number = page.number + 1  # page.number is 0-based
+            elevations.extend(self.extract_elevation_from_bbox(page, page_number))
 
-            # TODO: This return the first found elevation, but we might want to check all pages.
-            return self.extract_elevation_from_bbox(page, page_number)
+        return elevations
diff --git a/src/stratigraphy/metadata/metadata.py b/src/stratigraphy/metadata/metadata.py
index 4f725962..7848e712 100644
--- a/src/stratigraphy/metadata/metadata.py
+++ b/src/stratigraphy/metadata/metadata.py
@@ -1,11 +1,11 @@
 """Metadata for stratigraphy data."""
 
-import abc
 from dataclasses import dataclass
 from pathlib import Path
 from typing import NamedTuple
 
 import fitz
+from stratigraphy.data_extractor.data_extractor import FeatureOnPage
 from stratigraphy.metadata.coordinate_extraction import Coordinate, CoordinateExtractor
 from stratigraphy.metadata.elevation_extraction import Elevation, ElevationExtractor
 from stratigraphy.metadata.language_detection import detect_language_of_document
@@ -28,38 +28,39 @@ def to_json(self) -> dict:
 
 
 @dataclass
-class BoreholeMetadata(metaclass=abc.ABCMeta):
-    """Metadata for stratigraphy data."""
+class MetadataInDocument:
+    """Container for all stratigraphy metadata found in the document."""
 
-    elevation: Elevation | None = None
-    coordinates: Coordinate | None = None
-    language: str | None = None  # TODO: Change to Enum for the supported languages
-    filename: Path = None
-    page_dimensions: list[PageDimensions] = None
+    elevations: list[FeatureOnPage[Elevation]]
+    coordinates: list[FeatureOnPage[Coordinate]]
 
-    def __init__(
-        self,
-        language: str = None,
-        elevation: Elevation = None,
-        coordinates: Coordinate = None,
-        page_dimensions: list[PageDimensions] = None,
-        filename: Path = None,
-    ):
-        """Initializes the BoreholeMetadata object.
+    @classmethod
+    def from_document(cls, document: fitz.Document) -> "MetadataInDocument":
+        """Create a MetadataInDocument object from a document.
 
         Args:
-            Args:
-            language (str | None): The language of the document.
-            elevation (Elevation | None): The elevation information.
-            coordinates (Coordinate | None): The coordinates of the borehole.
-            page_dimensions (list[PageDimensions] | None): The dimensions of the pages in the document.
-            filename (Path | None): The name of the file.
+            document (fitz.Document): The document.
+
+        Returns:
+            MetadataInDocument: The metadata object.
         """
-        self.language = language
-        self.elevation = elevation
-        self.coordinates = coordinates
-        self.page_dimensions = page_dimensions
-        self.filename = filename
+        # Extract the coordinates of the borehole
+        coordinate_extractor = CoordinateExtractor()
+        coordinates = coordinate_extractor.extract_coordinates(document=document)
+
+        # Extract the elevation information
+        elevation_extractor = ElevationExtractor()
+        elevations = elevation_extractor.extract_elevation(document=document)
+
+        return cls(elevations=elevations, coordinates=coordinates)
+
+
+@dataclass
+class BoreholeMetadata:
+    """Metadata for stratigraphy data for a single borehole."""
+
+    elevation: FeatureOnPage[Elevation] | None = None
+    coordinates: FeatureOnPage[Coordinate] | None = None
 
     def to_json(self) -> dict:
         """Converts the object to a dictionary.
@@ -70,33 +71,54 @@ def to_json(self) -> dict:
         return {
             "elevation": self.elevation.to_json() if self.elevation else None,
             "coordinates": self.coordinates.to_json() if self.coordinates else None,
-            "language": self.language,
-            "page_dimensions": [page_dimensions.to_json() for page_dimensions in self.page_dimensions],
         }
 
-    def __str__(self) -> str:
-        """Converts the object to a string.
+    @classmethod
+    def from_json(cls, json_metadata: dict) -> "BoreholeMetadata":
+        """Converts a dictionary to an object.
+
+        Args:
+            json_metadata (dict): A dictionary representing the metadata.
 
         Returns:
-            str: The object as a string.
+            BoreholeMetadata: The metadata object.
         """
-        return (
-            f"StratigraphyMetadata("
-            f"elevation={self.elevation}, "
-            f"coordinates={self.coordinates} "
-            f"language={self.language}, "
-            f"page_dimensions={self.page_dimensions})"
+        return cls(
+            FeatureOnPage.from_json(json_metadata["elevation"], Elevation) if json_metadata["elevation"] else None,
+            FeatureOnPage.from_json(json_metadata["coordinates"], Coordinate)
+            if json_metadata["coordinates"]
+            else None,
         )
 
+
+@dataclass
+class FileMetadata:
+    """Class to store and extract metadata at the file level (common to all boreholes in the file)."""
+
+    language: str | None = None  # TODO: Change to Enum for the supported languages
+    filename: Path = None
+    page_dimensions: list[PageDimensions] = None
+
+    def to_json(self) -> dict:
+        """Converts the object to a dictionary.
+
+        Returns:
+            dict: The object as a dictionary.
+        """
+        return {
+            "language": self.language,
+            "page_dimensions": [page_dimensions.to_json() for page_dimensions in self.page_dimensions],
+        }
+
     @classmethod
-    def from_document(cls, document: fitz.Document) -> "BoreholeMetadata":
-        """Create a BoreholeMetadata object from a document.
+    def from_document(cls, document: fitz.Document) -> "FileMetadata":
+        """Create a FileMetadata object from a document.
 
         Args:
             document (fitz.Document): The document.
 
         Returns:
-            BoreholeMetadata: The metadata object.
+            FileMetadata: The file metadata object.
         """
         matching_params = read_params("matching_params.yml")
 
@@ -105,14 +127,6 @@ def from_document(cls, document: fitz.Document) -> "BoreholeMetadata":
             document, matching_params["default_language"], matching_params["material_description"].keys()
         )
 
-        # Extract the coordinates of the borehole
-        coordinate_extractor = CoordinateExtractor()
-        coordinates = coordinate_extractor.extract_coordinates(document=document)
-
-        # Extract the elevation information
-        elevation_extractor = ElevationExtractor()
-        elevation = elevation_extractor.extract_elevation(document=document)
-
         # Get the name of the document
         filename = Path(document.name)
 
@@ -126,14 +140,12 @@ def from_document(cls, document: fitz.Document) -> "BoreholeMetadata":
 
         return cls(
             language=language,
-            elevation=elevation,
-            coordinates=coordinates,
             filename=filename,
             page_dimensions=page_dimensions,
         )
 
     @classmethod
-    def from_json(cls, json_metadata: dict, filename: str) -> "BoreholeMetadata":
+    def from_json(cls, json_metadata: dict, filename: str) -> "FileMetadata":
         """Converts a dictionary to an object.
 
         Args:
@@ -141,66 +153,15 @@ def from_json(cls, json_metadata: dict, filename: str) -> "BoreholeMetadata":
             filename (str): The name of the file.
 
         Returns:
-            BoreholeMetadata: The metadata object.
+            FileMetadata: The metadata object.
         """
-        elevation = Elevation.from_json(json_metadata["elevation"]) if json_metadata["elevation"] is not None else None
-        coordinates = (
-            Coordinate.from_json(json_metadata["coordinates"]) if json_metadata["coordinates"] is not None else None
-        )
         language = json_metadata["language"]
         page_dimensions = [
             PageDimensions(width=page["width"], height=page["height"]) for page in json_metadata["page_dimensions"]
         ]
 
         return cls(
-            elevation=elevation,
-            coordinates=coordinates,
             language=language,
             page_dimensions=page_dimensions,
             filename=Path(filename),
         )
-
-
-@dataclass
-class OverallBoreholeMetadata:
-    """Metadata for stratigraphy data.
-
-    This class is a list of BoreholeMetadata objects. Each object corresponds to a
-    single file.
-    """
-
-    metadata_per_file: list[BoreholeMetadata] = None
-
-    def __init__(self):
-        """Initializes the BoreholeMetadataList object."""
-        self.metadata_per_file = []
-
-    def add_metadata(self, metadata: BoreholeMetadata) -> None:
-        """Add metadata to the list.
-
-        Args:
-            metadata (BoreholeMetadata): The metadata to add.
-        """
-        self.metadata_per_file.append(metadata)
-
-    def get_metadata(self, filename: str) -> BoreholeMetadata:
-        """Get the metadata for a specific file.
-
-        Args:
-            filename (str): The name of the file.
-
-        Returns:
-            BoreholeMetadata: The metadata for the file.
-        """
-        for metadata in self.metadata_per_file:
-            if metadata.filename.name == filename:
-                return metadata
-        return None
-
-    def to_json(self) -> dict:
-        """Converts the object to a dictionary.
-
-        Returns:
-            dict: The object as a dictionary.
-        """
-        return {metadata.filename.name: metadata.to_json() for metadata in self.metadata_per_file}
diff --git a/src/stratigraphy/util/borehole_predictions.py b/src/stratigraphy/util/borehole_predictions.py
new file mode 100644
index 00000000..881b3827
--- /dev/null
+++ b/src/stratigraphy/util/borehole_predictions.py
@@ -0,0 +1,120 @@
+"""Classes for the predictions per borehole, and for associating them with ground truth data."""
+
+import dataclasses
+
+from stratigraphy.depths_materials_column_pairs.bounding_boxes import PageBoundingBoxes
+from stratigraphy.groundwater.groundwater_extraction import GroundwatersInBorehole
+from stratigraphy.layer.layer import LayersInBorehole
+from stratigraphy.metadata.metadata import BoreholeMetadata
+
+
+@dataclasses.dataclass
+class BoreholePredictions:
+    """Class that hold predicted information about a single borehole."""
+
+    borehole_index: int
+    layers_in_borehole: LayersInBorehole
+    file_name: str
+    metadata: BoreholeMetadata
+    groundwater_in_borehole: GroundwatersInBorehole
+    bounding_boxes: list[PageBoundingBoxes]
+
+    def to_json(self) -> dict:
+        """Converts the object to a dictionary.
+
+        Returns:
+            dict: The object as a dictionary.
+        """
+        return {
+            "borehole_index": self.borehole_index,
+            "metadata": self.metadata.to_json(),
+            "layers": [layer.to_json() for layer in self.layers_in_borehole.layers],
+            "bounding_boxes": [bboxes.to_json() for bboxes in self.bounding_boxes],
+            "groundwater": self.groundwater_in_borehole.to_json() if self.groundwater_in_borehole is not None else [],
+        }
+
+    @classmethod
+    def from_json(cls, json_object, file_name) -> "BoreholePredictions":
+        """Extract a BoreholePrediction object from a json dictionary.
+
+        Args:
+            json_object (dict): the json object containing the informations of the borehole
+            file_name: the file name
+
+        Returns:
+            (BoreholePredictions): the extracted object
+        """
+        return cls(
+            json_object["borehole_index"],
+            LayersInBorehole.from_json(json_object["layers"]),
+            file_name,
+            BoreholeMetadata.from_json(json_object["metadata"]),
+            GroundwatersInBorehole.from_json(json_object["groundwater"]),
+            [PageBoundingBoxes.from_json(bbox_json) for bbox_json in json_object["bounding_boxes"]],
+        )
+
+
+@dataclasses.dataclass
+class BoreholePredictionsWithGroundTruth:
+    """Predictions for a specific borehole with associated ground truth."""
+
+    predictions: BoreholePredictions | None
+    ground_truth: dict
+
+
+@dataclasses.dataclass
+class BoreholeLayersWithGroundTruth:
+    """Stratigraphy predictions for a specific borehole with associated ground truth."""
+
+    layers: LayersInBorehole | None
+    ground_truth: list
+
+
+@dataclasses.dataclass
+class BoreholeGroundwaterWithGroundTruth:
+    """Groundwater predictions for a specific borehole with associated ground truth."""
+
+    groundwater: GroundwatersInBorehole | None
+    ground_truth: list
+
+
+@dataclasses.dataclass
+class BoreholeMetadataWithGroundTruth:
+    """Borehole metadata predictions for a specific borehole with associated ground truth."""
+
+    metadata: BoreholeMetadata | None
+    ground_truth: dict
+
+
+@dataclasses.dataclass
+class FilePredictionsWithGroundTruth:
+    """All predictions for a specific file with associated ground truth."""
+
+    filename: str
+    language: str
+    boreholes: list[BoreholePredictionsWithGroundTruth]
+
+
+@dataclasses.dataclass
+class FileMetadataWithGroundTruth:
+    """All borehole metadata predictions for a specific file with associated ground truth."""
+
+    filename: str
+    boreholes: list[BoreholeMetadataWithGroundTruth]
+
+
+@dataclasses.dataclass
+class FileLayersWithGroundTruth:
+    """All stratigraphy predictions for a specific file with associated ground truth."""
+
+    filename: str
+    language: str
+    boreholes: list[BoreholeLayersWithGroundTruth]
+
+
+@dataclasses.dataclass
+class FileGroundwaterWithGroundTruth:
+    """All groundwater predictions for a specific file with associated ground truth."""
+
+    filename: str
+    boreholes: list[BoreholeGroundwaterWithGroundTruth]
diff --git a/src/stratigraphy/util/file_predictions.py b/src/stratigraphy/util/file_predictions.py
new file mode 100644
index 00000000..6447a688
--- /dev/null
+++ b/src/stratigraphy/util/file_predictions.py
@@ -0,0 +1,26 @@
+"""Classes for predictions per PDF file."""
+
+import dataclasses
+
+from stratigraphy.metadata.metadata import FileMetadata
+from stratigraphy.util.borehole_predictions import BoreholePredictions
+
+
+@dataclasses.dataclass
+class FilePredictions:
+    """A class to represent predictions for a single file."""
+
+    borehole_predictions_list: list[BoreholePredictions]
+    file_metadata: FileMetadata
+    file_name: str
+
+    def to_json(self) -> dict:
+        """Converts the object to a dictionary.
+
+        Returns:
+            dict: The object as a dictionary.
+        """
+        return {
+            **self.file_metadata.to_json(),
+            "boreholes": [borehole.to_json() for borehole in self.borehole_predictions_list],
+        }
diff --git a/src/stratigraphy/util/overall_file_predictions.py b/src/stratigraphy/util/overall_file_predictions.py
new file mode 100644
index 00000000..33f5ee65
--- /dev/null
+++ b/src/stratigraphy/util/overall_file_predictions.py
@@ -0,0 +1,93 @@
+"""Classes for predictions per PDF file."""
+
+from stratigraphy.benchmark.ground_truth import GroundTruth
+from stratigraphy.evaluation.layer_evaluator import LayerEvaluator
+from stratigraphy.metadata.metadata import FileMetadata
+from stratigraphy.util.borehole_predictions import (
+    BoreholePredictions,
+    FilePredictionsWithGroundTruth,
+)
+from stratigraphy.util.file_predictions import FilePredictions
+from stratigraphy.util.predictions import AllBoreholePredictionsWithGroundTruth
+
+
+class OverallFilePredictions:
+    """A class to represent predictions for all files."""
+
+    def __init__(self) -> None:
+        """Initializes the OverallFilePredictions object."""
+        self.file_predictions_list: list[FilePredictions] = []
+
+    def add_file_predictions(self, file_predictions: FilePredictions) -> None:
+        """Add file predictions to the list of file predictions.
+
+        Args:
+            file_predictions (FilePredictions): The file predictions to add.
+        """
+        self.file_predictions_list.append(file_predictions)
+
+    def get_metadata_as_dict(self) -> dict:
+        """Returns the metadata of the predictions as a dictionary.
+
+        Returns:
+            dict: The metadata of the predictions as a dictionary.
+        """
+        return {
+            "_".join([file_prediction.file_name, str(borehole_prediction.borehole_index)]): {
+                "file_metadata": file_prediction.file_metadata.to_json(),
+                "borehole_metadata": borehole_prediction.metadata.to_json(),
+            }
+            for file_prediction in self.file_predictions_list
+            for borehole_prediction in file_prediction.borehole_predictions_list
+        }
+
+    def to_json(self) -> dict:
+        """Converts the object to a dictionary by merging individual file predictions.
+
+        Returns:
+            dict: A dictionary representation of the object.
+        """
+        return {fp.file_name: fp.to_json() for fp in self.file_predictions_list}
+
+    @classmethod
+    def from_json(cls, prediction_from_file: dict) -> "OverallFilePredictions":
+        """Converts a dictionary to an object.
+
+        Args:
+            prediction_from_file (dict): A dictionary representing the predictions.
+
+        Returns:
+            OverallFilePredictions: The object.
+        """
+        overall_file_predictions = OverallFilePredictions()
+        for file_name, file_data in prediction_from_file.items():
+            file_metadata = FileMetadata.from_json(file_data, file_name)
+
+            borehole_list = [BoreholePredictions.from_json(bh_data, file_name) for bh_data in file_data["boreholes"]]
+
+            overall_file_predictions.add_file_predictions(FilePredictions(borehole_list, file_metadata, file_name))
+        return overall_file_predictions
+
+    def match_with_ground_truth(self, ground_truth: GroundTruth) -> AllBoreholePredictionsWithGroundTruth:
+        """Match the extracted boreholes with corresponding boreholes in the ground truth data.
+
+        This is done by comparing the layers of the extracted boreholes with those in the groundtruth.
+
+        Args:
+            ground_truth (GroundTruth): The ground truth.
+
+        Returns:
+            AllBoreholePredictionsWithGroundTruth: all predictions per borehole with associated ground truth data.
+        """
+        files = []
+        for file_predictions in self.file_predictions_list:
+            boreholes = []
+            ground_truth_for_file = ground_truth.for_file(file_predictions.file_name)
+            if ground_truth_for_file:
+                boreholes = LayerEvaluator.match_predictions_with_ground_truth(file_predictions, ground_truth_for_file)
+            files.append(
+                FilePredictionsWithGroundTruth(
+                    file_predictions.file_name, file_predictions.file_metadata.language, boreholes
+                )
+            )
+        return AllBoreholePredictionsWithGroundTruth(files)
diff --git a/src/stratigraphy/util/predictions.py b/src/stratigraphy/util/predictions.py
index 51e53f5f..a02c54a2 100644
--- a/src/stratigraphy/util/predictions.py
+++ b/src/stratigraphy/util/predictions.py
@@ -1,167 +1,191 @@
 """This module contains classes for predictions."""
 
+import dataclasses
 import logging
+from copy import deepcopy
+from typing import TypeVar
 
-from stratigraphy.benchmark.ground_truth import GroundTruth
 from stratigraphy.benchmark.metrics import OverallMetricsCatalog
 from stratigraphy.data_extractor.data_extractor import FeatureOnPage
-from stratigraphy.depths_materials_column_pairs.bounding_boxes import BoundingBoxes
 from stratigraphy.evaluation.evaluation_dataclasses import OverallBoreholeMetadataMetrics
 from stratigraphy.evaluation.groundwater_evaluator import GroundwaterEvaluator
 from stratigraphy.evaluation.layer_evaluator import LayerEvaluator
 from stratigraphy.evaluation.metadata_evaluator import MetadataEvaluator
-from stratigraphy.groundwater.groundwater_extraction import Groundwater, GroundwaterInDocument
-from stratigraphy.layer.layer import Layer, LayersInDocument
-from stratigraphy.metadata.metadata import BoreholeMetadata, OverallBoreholeMetadata
+from stratigraphy.groundwater.groundwater_extraction import GroundwaterInDocument, GroundwatersInBorehole
+from stratigraphy.layer.layer import LayersInBorehole, LayersInDocument
+from stratigraphy.metadata.coordinate_extraction import Coordinate
+from stratigraphy.metadata.elevation_extraction import Elevation
+from stratigraphy.metadata.metadata import BoreholeMetadata
+from stratigraphy.util.borehole_predictions import (
+    BoreholeGroundwaterWithGroundTruth,
+    BoreholeLayersWithGroundTruth,
+    BoreholeMetadataWithGroundTruth,
+    BoreholePredictions,
+    FileGroundwaterWithGroundTruth,
+    FileLayersWithGroundTruth,
+    FileMetadataWithGroundTruth,
+    FilePredictionsWithGroundTruth,
+)
 
 logger = logging.getLogger(__name__)
 
+T = TypeVar("T")
 
-class FilePredictions:
-    """A class to represent predictions for a single file."""
+
+class BoreholeListBuilder:
+    """Class responsible for constructing a list of BoreholePredictions.
+
+    This is done by aggregating and aligning various extracted document elements.
+
+    This class ensures that all borehole-related lists are extended to match the number of detected boreholes
+    and then builds BoreholePredictions objects accordingly.
+    """
 
     def __init__(
         self,
-        layers_in_document: LayersInDocument,
+        layers_with_bb_in_document: LayersInDocument,
         file_name: str,
-        metadata: BoreholeMetadata,
-        groundwater: GroundwaterInDocument,
-        bounding_boxes: list[BoundingBoxes],
+        groundwater_in_doc: GroundwaterInDocument,
+        elevations_list: list[FeatureOnPage[Elevation] | None],
+        coordinates_list: list[FeatureOnPage[Coordinate] | None],
     ):
-        self.layers_in_document: LayersInDocument = layers_in_document
-        self.bounding_boxes: list[BoundingBoxes] = bounding_boxes
-        self.file_name: str = file_name
-        self.metadata: BoreholeMetadata = metadata
-        self.groundwater: GroundwaterInDocument = groundwater
+        """Initializes the BoreholeListBuilder with extracted borehole-related data.
 
-    def to_json(self) -> dict:
-        """Converts the object to a dictionary.
-
-        Returns:
-            dict: The object as a dictionary.
+        Args:
+            layers_with_bb_in_document (LayersInDocument): Object containing borehole layers extracted from the
+                document, along with its corespoding bounding boxes.
+            file_name (str): The name of the processed document.
+            groundwater_in_doc (GroundwaterInDocument): Contains detected groundwater entries for boreholes.
+
+            elevations_list (list[FeatureOnPage[Elevation] | None]): List of terrain elevation values for detected
+                boreholes.
+            coordinates_list (list[FeatureOnPage[Coordinate] | None]): List of borehole coordinates.
         """
-        return {
-            "metadata": self.metadata.to_json(),
-            "layers": [layer.to_json() for layer in self.layers_in_document.layers],
-            "bounding_boxes": [bboxes.to_json() for bboxes in self.bounding_boxes],
-            "groundwater": self.groundwater.to_json() if self.groundwater is not None else [],
-        }
-
+        self._layers_with_bb_in_document = layers_with_bb_in_document
+        self._file_name = file_name
+        self._groundwater_in_doc = groundwater_in_doc
+        self._elevations_list = elevations_list
+        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()
+
+        return [
+            BoreholePredictions(
+                borehole_index,
+                LayersInBorehole(layers_in_borehole_with_bb.predictions),
+                self._file_name,
+                BoreholeMetadata(elevation, coordinate),
+                groundwater,
+                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,
+                )
+            )
+        ]
 
-class OverallFilePredictions:
-    """A class to represent predictions for all files."""
+    def _extend_length_to_match_boreholes_num_pred(self) -> None:
+        """Ensures that all lists have the same length by duplicating elements if necessary."""
+        num_boreholes = len(self._layers_with_bb_in_document.boreholes_layers_with_bb)
 
-    def __init__(self) -> None:
-        """Initializes the OverallFilePredictions object."""
-        self.file_predictions_list: list[FilePredictions] = []
+        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)
 
-    def add_file_predictions(self, file_predictions: FilePredictions) -> None:
-        """Add file predictions to the list of file predictions.
+    @staticmethod
+    def _extend_list(lst: list[T], default_elem: T, target_length: int) -> list[T]:
+        """Extends a list with deep copies of a base element until it reaches the target length.
 
-        Args:
-            file_predictions (FilePredictions): The file predictions to add.
+        deepcopy is necessary, because the is_correct attribute is already stored on this object, but the same
+        extracted value might be correct on one borehole and incorrect on another one.
         """
-        self.file_predictions_list.append(file_predictions)
 
-    def get_metadata_as_dict(self) -> dict:
-        """Returns the metadata of the predictions as a dictionary.
+        def create_new_elem():
+            return deepcopy(lst[0]) if lst else default_elem
 
-        Returns:
-            dict: The metadata of the predictions as a dictionary.
-        """
-        return {
-            file_prediction.file_name: file_prediction.metadata.to_json()
-            for file_prediction in self.file_predictions_list
-        }
+        while len(lst) < target_length:
+            lst.append(create_new_elem())  # Append copies to match the required length
 
-    def to_json(self) -> dict:
-        """Converts the object to a dictionary by merging individual file predictions.
+        return lst[:target_length]
 
-        Returns:
-            dict: A dictionary representation of the object.
-        """
-        return {fp.file_name: fp.to_json() for fp in self.file_predictions_list}
 
-    @classmethod
-    def from_json(cls, prediction_from_file: dict) -> "OverallFilePredictions":
-        """Converts a dictionary to an object.
+@dataclasses.dataclass
+class AllBoreholePredictionsWithGroundTruth:
+    """Class for evaluating all files, after individual boreholes have been match with their ground truth data."""
 
-        Args:
-            prediction_from_file (dict): A dictionary representing the predictions.
+    predictions_list: list[FilePredictionsWithGroundTruth]
 
-        Returns:
-            OverallFilePredictions: The object.
-        """
-        overall_file_predictions = OverallFilePredictions()
-        for file_name, file_data in prediction_from_file.items():
-            metadata = BoreholeMetadata.from_json(file_data["metadata"], file_name)
-
-            layers = [Layer.from_json(data) for data in file_data["layers"]]
-            layers_in_doc = LayersInDocument(layers=layers, filename=file_name)
-
-            bounding_boxes = [BoundingBoxes.from_json(bboxes) for bboxes in file_data["bounding_boxes"]]
-
-            groundwater_entries = [FeatureOnPage.from_json(entry, Groundwater) for entry in file_data["groundwater"]]
-            groundwater_in_document = GroundwaterInDocument(groundwater=groundwater_entries, filename=file_name)
-            overall_file_predictions.add_file_predictions(
-                FilePredictions(
-                    layers_in_document=layers_in_doc,
-                    file_name=file_name,
-                    metadata=metadata,
-                    bounding_boxes=bounding_boxes,
-                    groundwater=groundwater_in_document,
-                )
-            )
-        return overall_file_predictions
-
-    ############################################################################################################
-    ### Evaluation methods
-    ############################################################################################################
-
-    def evaluate_metadata_extraction(self, ground_truth: GroundTruth) -> OverallBoreholeMetadataMetrics:
+    def evaluate_metadata_extraction(self) -> OverallBoreholeMetadataMetrics:
         """Evaluate the metadata extraction of the predictions against the ground truth.
 
-        Args:
-            ground_truth (GroundTruth): The ground truth.
+        Returns:
+            OverallBoreholeMetadataMetrics: the computed metrics for the metadata.
         """
-        metadata_per_file: OverallBoreholeMetadata = OverallBoreholeMetadata()
+        metadata_list = [
+            FileMetadataWithGroundTruth(
+                file.filename,
+                [
+                    BoreholeMetadataWithGroundTruth(
+                        predictions.predictions.metadata if predictions.predictions else None,
+                        predictions.ground_truth.get("metadata", {}),
+                    )
+                    for predictions in file.boreholes
+                ],
+            )
+            for file in self.predictions_list
+        ]
 
-        for file_prediction in self.file_predictions_list:
-            metadata_per_file.add_metadata(file_prediction.metadata)
-        return MetadataEvaluator(metadata_per_file, ground_truth).evaluate()
+        return MetadataEvaluator(metadata_list).evaluate()
 
-    def evaluate_geology(self, ground_truth: GroundTruth) -> OverallMetricsCatalog | None:
+    def evaluate_geology(self) -> OverallMetricsCatalog:
         """Evaluate the borehole extraction predictions.
 
-        Args:
-            ground_truth (GroundTruth): The ground truth.
-
         Returns:
             OverallMetricsCatalog: A OverallMetricsCatalog that maps a metrics name to the corresponding
             OverallMetrics object. If no ground truth is available, None is returned.
         """
-        for file_predictions in self.file_predictions_list:
-            ground_truth_for_file = ground_truth.for_file(file_predictions.file_name)
-            if ground_truth_for_file:
-                LayerEvaluator.evaluate_borehole(
-                    file_predictions.layers_in_document.layers, ground_truth_for_file["layers"]
-                )
-
-        languages = set(fp.metadata.language for fp in self.file_predictions_list)
+        languages = set(fp.language for fp in self.predictions_list)
         all_metrics = OverallMetricsCatalog(languages=languages)
 
-        evaluator = LayerEvaluator(
-            [prediction.layers_in_document for prediction in self.file_predictions_list], ground_truth
-        )
+        layers_list = [
+            FileLayersWithGroundTruth(
+                file.filename,
+                file.language,
+                [
+                    BoreholeLayersWithGroundTruth(
+                        predictions.predictions.layers_in_borehole if predictions.predictions else None,
+                        predictions.ground_truth.get("layers", []),
+                    )
+                    for predictions in file.boreholes
+                ],
+            )
+            for file in self.predictions_list
+        ]
+        evaluator = LayerEvaluator(layers_list)
         all_metrics.layer_metrics = evaluator.get_layer_metrics()
         all_metrics.depth_interval_metrics = evaluator.get_depth_interval_metrics()
 
-        layers_in_doc_by_language = {language: [] for language in languages}
-        for file_prediction in self.file_predictions_list:
-            layers_in_doc_by_language[file_prediction.metadata.language].append(file_prediction.layers_in_document)
+        predictions_by_language = {language: [] for language in languages}
+        for borehole_data in layers_list:
+            # even if metadata can be different for boreholes in the same document, langage is the same (take index 0)
+            predictions_by_language[borehole_data.language].append(borehole_data)
 
-        for language, layers_in_doc_list in layers_in_doc_by_language.items():
-            evaluator = LayerEvaluator(layers_in_doc_list, ground_truth)
+        for language, language_predictions_list in predictions_by_language.items():
+            evaluator = LayerEvaluator(language_predictions_list)
             setattr(
                 all_metrics,
                 f"{language}_layer_metrics",
@@ -178,8 +202,21 @@ def evaluate_geology(self, ground_truth: GroundTruth) -> OverallMetricsCatalog |
             all_metrics.depth_interval_metrics.macro_precision() * 100,
         )
 
-        groundwater_entries = [file_prediction.groundwater for file_prediction in self.file_predictions_list]
-        overall_groundwater_metrics = GroundwaterEvaluator(groundwater_entries, ground_truth).evaluate()
+        # TODO groundwater should not be in evaluate_geology(), it should be handle by a higher-level function call
+        groundwater_list = [
+            FileGroundwaterWithGroundTruth(
+                file.filename,
+                [
+                    BoreholeGroundwaterWithGroundTruth(
+                        predictions.predictions.groundwater_in_borehole if predictions.predictions else None,
+                        predictions.ground_truth.get("groundwater", []) or [],  # value can be `None`
+                    )
+                    for predictions in file.boreholes
+                ],
+            )
+            for file in self.predictions_list
+        ]
+        overall_groundwater_metrics = GroundwaterEvaluator(groundwater_list).evaluate()
         all_metrics.groundwater_metrics = overall_groundwater_metrics.groundwater_metrics_to_overall_metrics()
         all_metrics.groundwater_depth_metrics = (
             overall_groundwater_metrics.groundwater_depth_metrics_to_overall_metrics()


Test patch:
diff --git a/tests/test_coordinate_extraction.py b/tests/test_coordinate_extraction.py
index a4925d99..c66320e8 100644
--- a/tests/test_coordinate_extraction.py
+++ b/tests/test_coordinate_extraction.py
@@ -16,10 +16,7 @@
 def test_strLV95():  # noqa: D103
     """Test the string representation of an LV95Coordinate object."""
     coord = LV95Coordinate(
-        east=CoordinateEntry(coordinate_value=2789456),
-        north=CoordinateEntry(coordinate_value=1123012),
-        rect=fitz.Rect(),
-        page=1,
+        east=CoordinateEntry(coordinate_value=2789456), north=CoordinateEntry(coordinate_value=1123012)
     )
     assert str(coord) == "E: 2789456, N: 1123012"
 
@@ -27,19 +24,16 @@ def test_strLV95():  # noqa: D103
 def test_to_jsonLV95():  # noqa: D103
     """Test the to_json method of an LV95Coordinate object."""
     coord = LV95Coordinate(
-        east=CoordinateEntry(coordinate_value=2789456),
-        north=CoordinateEntry(coordinate_value=1123012),
-        rect=fitz.Rect(0, 1, 2, 3),
-        page=1,
+        east=CoordinateEntry(coordinate_value=2789456), north=CoordinateEntry(coordinate_value=1123012)
     )
-    assert coord.to_json() == {"E": 2789456, "N": 1123012, "rect": [0, 1, 2, 3], "page": 1}
+    assert coord.to_json() == {"E": 2789456, "N": 1123012}
 
 
 def test_swap_coordinates():  # noqa: D103
     """Test the swapping of coordinates in an LV95Coordinate object."""
     north = CoordinateEntry(coordinate_value=789456)
     east = CoordinateEntry(coordinate_value=123012)
-    coord = LV95Coordinate(north=north, east=east, rect=fitz.Rect(), page=1)
+    coord = LV95Coordinate(north=north, east=east)
     assert coord.east == north
     assert coord.north == east
 
@@ -47,10 +41,7 @@ def test_swap_coordinates():  # noqa: D103
 def test_strLV03():  # noqa: D103
     """Test the string representation of an LV03Coordinate object."""
     coord = LV03Coordinate(
-        east=CoordinateEntry(coordinate_value=789456),
-        north=CoordinateEntry(coordinate_value=123012),
-        rect=fitz.Rect(),
-        page=1,
+        east=CoordinateEntry(coordinate_value=789456), north=CoordinateEntry(coordinate_value=123012)
     )
     assert str(coord) == "E: 789456, N: 123012"
 
@@ -58,12 +49,9 @@ def test_strLV03():  # noqa: D103
 def test_to_jsonLV03():  # noqa: D103
     """Test the to_json method of an LV03Coordinate object."""
     coord = LV03Coordinate(
-        east=CoordinateEntry(coordinate_value=789456),
-        north=CoordinateEntry(coordinate_value=123012),
-        rect=fitz.Rect(0, 1, 2, 3),
-        page=1,
+        east=CoordinateEntry(coordinate_value=789456), north=CoordinateEntry(coordinate_value=123012)
     )
-    assert coord.to_json() == {"E": 789456, "N": 123012, "rect": [0, 1, 2, 3], "page": 1}
+    assert coord.to_json() == {"E": 789456, "N": 123012}
 
 
 doc = fitz.open(DATAPATH.parent / "example" / "example_borehole_profile.pdf")
@@ -74,21 +62,21 @@ def test_to_jsonLV03():  # noqa: D103
 def test_CoordinateExtractor_extract_coordinates():  # noqa: D103
     """Test the extraction of coordinates from a PDF document."""
     # Assuming there is a method called 'extract' in CoordinateExtractor class
-    coordinates = extractor.extract_coordinates(doc)
+    coordinates = extractor.extract_coordinates(doc)[0]
     # Check if the returned value is a list
-    assert isinstance(coordinates, Coordinate)
-    assert repr(coordinates.east) == "615'790.0"
-    assert repr(coordinates.north) == "157'500.0"
+    assert isinstance(coordinates.feature, Coordinate)
+    assert repr(coordinates.feature.east) == "615'790.0"
+    assert repr(coordinates.feature.north) == "157'500.0"
 
 
 def test_CoordinateExtractor_extract_coordinates_with_digits_in_coordinates():  # noqa: D103
     """Test the extraction of coordinates from a PDF document with digits in the coordinates."""
     # Assuming there is a method called 'extract' in CoordinateExtractor class
-    coordinates = CoordinateExtractor().extract_coordinates(doc_with_digits_in_coordinates)
+    coordinates = CoordinateExtractor().extract_coordinates(doc_with_digits_in_coordinates)[0]
     # Check if the returned value is a list
-    assert isinstance(coordinates, Coordinate)
-    assert repr(coordinates.east) == "607'562.0"
-    assert repr(coordinates.north) == "187'087.5"
+    assert isinstance(coordinates.feature, Coordinate)
+    assert repr(coordinates.feature.east) == "607'562.0"
+    assert repr(coordinates.feature.north) == "187'087.5"
 
 
 def _create_simple_lines(text_lines: list[str]) -> list[TextLine]:
@@ -139,12 +127,12 @@ def test_CoordinateExtractor_get_coordinates_with_x_y_labels():  # noqa: D103
     coordinates = extractor.get_coordinates_with_x_y_labels(lines, page=1)
 
     # coordinates with explicit "X" and "Y" labels are found, even when they are further apart
-    assert coordinates[0].east.coordinate_value == 2600000
-    assert coordinates[0].north.coordinate_value == 1200000
+    assert coordinates[0].feature.east.coordinate_value == 2600000
+    assert coordinates[0].feature.north.coordinate_value == 1200000
     # 1st X-value is only combined with the 1st Y-value, 2nd X-value with 2nd Y-value, etc.
     # Values are swapped when necessary
-    assert coordinates[1].east.coordinate_value == 2600001
-    assert coordinates[1].north.coordinate_value == 1200001
+    assert coordinates[1].feature.east.coordinate_value == 2600001
+    assert coordinates[1].feature.north.coordinate_value == 1200001
     # ignore invalid coordinates and additional values that are only available with "X" or "Y" label, but not both
     assert len(coordinates) == 2
 
@@ -212,11 +200,11 @@ def test_CoordinateExtractor_get_coordinates_near_key():  # noqa: D103
     coordinates = extractor.get_coordinates_near_key(lines, page=1)
 
     # coordinates on the same line as the key are found, and OCR errors are corrected
-    assert coordinates[0].east.coordinate_value == 615790
-    assert coordinates[0].north.coordinate_value == 157500
+    assert coordinates[0].feature.east.coordinate_value == 615790
+    assert coordinates[0].feature.north.coordinate_value == 157500
     # coordinates immediately below is also found
-    assert coordinates[1].east.coordinate_value == 600001
-    assert coordinates[1].north.coordinate_value == 200001
+    assert coordinates[1].feature.east.coordinate_value == 600001
+    assert coordinates[1].feature.north.coordinate_value == 200001
     # no coordinates are found far down from the coordinates key
     assert len(coordinates) == 2
 
@@ -251,8 +239,8 @@ def test_CoordinateExtractor_get_coordinates_from_lines(text, expected):  # noqa
     lines = _create_simple_lines([text])
     coordinates = extractor.get_coordinates_from_lines(lines, page=1)
     expected_east, expected_north = expected
-    assert coordinates[0].east.coordinate_value == expected_east
-    assert coordinates[0].north.coordinate_value == expected_north
+    assert coordinates[0].feature.east.coordinate_value == expected_east
+    assert coordinates[0].feature.north.coordinate_value == expected_north
     assert coordinates[0].page == 1
 
 
@@ -273,31 +261,31 @@ def test_CoordinateExtractor_get_coordinates_from_lines_rect():  # noqa: D103
     # Example from 269126143-bp.pdf (a slash in the middle of the coordinates as misread by OCR as the digit 1)
     lines = _create_simple_lines(["269578211260032"])
     coordinates = extractor.get_coordinates_from_lines(lines, page=1)
-    assert coordinates[0].east.coordinate_value == 2695782
-    assert coordinates[0].north.coordinate_value == 1260032
+    assert coordinates[0].feature.east.coordinate_value == 2695782
+    assert coordinates[0].feature.north.coordinate_value == 1260032
 
 
 def test_get_single_decimal_coordinates():
     """Test the extraction of decimal coordinates from a list of text lines."""
     lines = _create_simple_lines(["615.790.6 / 157.500.5"])
     coordinates = extractor.get_coordinates_from_lines(lines, page=1)
-    assert coordinates[0].east.coordinate_value == 615790.6
-    assert coordinates[0].north.coordinate_value == 157500.5
+    assert coordinates[0].feature.east.coordinate_value == 615790.6
+    assert coordinates[0].feature.north.coordinate_value == 157500.5
 
     lines = _create_simple_lines(["2600000.6 / 1200000.5"])
     coordinates = extractor.get_coordinates_from_lines(lines, page=1)
-    assert coordinates[0].east.coordinate_value == 2600000.6
-    assert coordinates[0].north.coordinate_value == 1200000.5
+    assert coordinates[0].feature.east.coordinate_value == 2600000.6
+    assert coordinates[0].feature.north.coordinate_value == 1200000.5
 
 
 def test_get_double_decimal_coordinates():
     """Test the extraction of decimal coordinates from a list of text lines."""
     lines = _create_simple_lines(["615.790.64 / 157.500.55"])
     coordinates = extractor.get_coordinates_from_lines(lines, page=1)
-    assert coordinates[0].east.coordinate_value == 615790.64
-    assert coordinates[0].north.coordinate_value == 157500.55
+    assert coordinates[0].feature.east.coordinate_value == 615790.64
+    assert coordinates[0].feature.north.coordinate_value == 157500.55
 
     lines = _create_simple_lines(["2600000.64 / 1200000.55"])
     coordinates = extractor.get_coordinates_from_lines(lines, page=1)
-    assert coordinates[0].east.coordinate_value == 2600000.64
-    assert coordinates[0].north.coordinate_value == 1200000.55
+    assert coordinates[0].feature.east.coordinate_value == 2600000.64
+    assert coordinates[0].feature.north.coordinate_value == 1200000.55
diff --git a/tests/test_groundwater.py b/tests/test_groundwater.py
index 1e475410..ed4c4430 100644
--- a/tests/test_groundwater.py
+++ b/tests/test_groundwater.py
@@ -9,7 +9,8 @@
     GroundwaterMetrics,
     OverallGroundwaterMetrics,
 )
-from stratigraphy.groundwater.groundwater_extraction import Groundwater, GroundwaterInDocument
+from stratigraphy.groundwater.groundwater_extraction import Groundwater, GroundwatersInBorehole
+from stratigraphy.util.borehole_predictions import BoreholeGroundwaterWithGroundTruth, FileGroundwaterWithGroundTruth
 
 
 @pytest.fixture
@@ -24,6 +25,36 @@ def groundtruth():
     return GroundTruth("example/example_gw_groundtruth.json")
 
 
+@pytest.fixture
+def groundwater_at_2m22() -> dict:
+    """Fixture that returns an Groundwater object (embeded in a FeatureOnPage)."""
+    return FeatureOnPage.from_json(
+        {
+            "depth": 2.22,
+            "date": "2016-04-18",
+            "elevation": 448.07,
+            "page": 1,
+            "rect": [0, 0, 100, 100],
+        },
+        Groundwater,
+    )
+
+
+@pytest.fixture
+def groundwater_at_3m22() -> dict:
+    """Fixture that returns another Groundwater object (embeded in a FeatureOnPage)."""
+    return FeatureOnPage.from_json(
+        {
+            "depth": 3.22,
+            "date": "2016-04-20",
+            "elevation": 447.07,
+            "page": 1,
+            "rect": [0, 0, 100, 100],
+        },
+        Groundwater,
+    )
+
+
 def test_add_groundwater_metrics(sample_metrics):
     """Test adding GroundwaterMetrics to OverallGroundwaterMetrics."""
     overall_metrics = OverallGroundwaterMetrics()
@@ -63,22 +94,32 @@ def test_groundwater_depth_metrics_to_overall_metrics(sample_metrics):
     assert overall.metrics["file_depth"] == gw_metrics.groundwater_depth_metrics
 
 
-def test_evaluate_with_ground_truth(groundtruth):
+def test_evaluate_with_ground_truth(groundtruth, groundwater_at_2m22, groundwater_at_3m22):
     """Test the evaluate method with available ground truth data."""
-    # Sample groundwater entries
-    groundwater_entries = [
-        GroundwaterInDocument(
-            filename="example_borehole_profile.pdf",
-            groundwater=[
-                FeatureOnPage.from_json(
-                    {"depth": 2.22, "date": "2016-04-18", "elevation": 448.07, "page": 1, "rect": [0, 0, 100, 100]},
-                    Groundwater,
-                )
-            ],
-        )
-    ]
-
-    evaluator = GroundwaterEvaluator(groundwater_entries, groundtruth)
+    # In this test, there is one borehole, with two groundwater measurement for it.
+    groundwater_entries = {
+        "example_borehole_profile.pdf": [GroundwatersInBorehole([groundwater_at_2m22, groundwater_at_3m22])]
+    }
+
+    # dictionary used to "manually" build the FileGroundwaterWithGroundTruth object
+    pred_to_gt_matching = {"example_borehole_profile.pdf": {0: 0}}
+    evaluator = GroundwaterEvaluator(
+        groundwater_list=[
+            FileGroundwaterWithGroundTruth(
+                filename=filename,
+                boreholes=[
+                    BoreholeGroundwaterWithGroundTruth(
+                        groundwater=groundwaterinborehole,
+                        ground_truth=groundtruth.for_file(filename).get(pred_to_gt_matching[filename][pred_idx])[
+                            "groundwater"
+                        ],
+                    )
+                    for pred_idx, groundwaterinborehole in enumerate(groundwaterinborehole_list)
+                ],
+            )
+            for filename, groundwaterinborehole_list in groundwater_entries.items()
+        ]
+    )
     overall_metrics = evaluator.evaluate()
 
     # Assertions
@@ -88,35 +129,49 @@ def test_evaluate_with_ground_truth(groundtruth):
     assert overall_metrics.groundwater_metrics[0].groundwater_metrics.precision == 1.0
 
 
-def test_evaluate_multiple_entries(groundtruth):
-    """Test the evaluate method with multiple groundwater entries."""
+def test_evaluate_multiple_documents(groundtruth, groundwater_at_2m22, groundwater_at_3m22):
+    """Test the evaluate method with multiple groundwater entries on two documents.
+
+    On the first document, there is one borehole with two groundwater measurement (like in the previous test).
+    On the second document, there is two boreholes, each with one groundwater measurement. For those boreholes, the
+    matching with the ground truth is 0->1 and 1->0. Meaning that the first borehole of the prediction matches the
+    second entry in the groudtruth file.
+    """
     # Sample groundwater entries
-    groundwater_entries = [
-        GroundwaterInDocument(
-            filename="example_borehole_profile.pdf",
-            groundwater=[
-                FeatureOnPage.from_json(
-                    {"depth": 2.22, "date": "2016-04-18", "elevation": 448.07, "page": 1, "rect": [0, 0, 100, 100]},
-                    Groundwater,
-                ),
-                FeatureOnPage.from_json(
-                    {"depth": 3.22, "date": "2016-04-20", "elevation": 447.07, "page": 1, "rect": [0, 0, 100, 100]},
-                    Groundwater,
-                ),
-            ],
-        ),
-        GroundwaterInDocument(
-            filename="example_borehole_profile_2.pdf",
-            groundwater=[
-                FeatureOnPage.from_json(
-                    {"depth": 3.22, "date": "2016-04-20", "elevation": 447.07, "page": 1, "rect": [0, 0, 100, 100]},
-                    Groundwater,
-                )
-            ],
-        ),
-    ]
-
-    evaluator = GroundwaterEvaluator(groundwater_entries, groundtruth)
+    gt_matching_index_example = {0: 0}
+    gt_matching_index_example_2 = {0: 1, 1: 0}
+    evaluator = GroundwaterEvaluator(
+        groundwater_list=[
+            FileGroundwaterWithGroundTruth(
+                filename="example_borehole_profile.pdf",
+                boreholes=[
+                    BoreholeGroundwaterWithGroundTruth(
+                        groundwater=GroundwatersInBorehole([groundwater_at_2m22, groundwater_at_3m22]),
+                        ground_truth=groundtruth.for_file("example_borehole_profile.pdf").get(
+                            gt_matching_index_example[0]
+                        )["groundwater"],
+                    )
+                ],
+            ),
+            FileGroundwaterWithGroundTruth(
+                filename="example_borehole_profile_2.pdf",
+                boreholes=[
+                    BoreholeGroundwaterWithGroundTruth(
+                        groundwater=GroundwatersInBorehole([groundwater_at_2m22]),
+                        ground_truth=groundtruth.for_file("example_borehole_profile_2.pdf").get(
+                            gt_matching_index_example_2[0]
+                        )["groundwater"],
+                    ),
+                    BoreholeGroundwaterWithGroundTruth(
+                        groundwater=GroundwatersInBorehole([groundwater_at_3m22]),
+                        ground_truth=groundtruth.for_file("example_borehole_profile_2.pdf").get(
+                            gt_matching_index_example_2[1]
+                        )["groundwater"],
+                    ),
+                ],
+            ),
+        ]
+    )
     overall_metrics = evaluator.evaluate()
 
     # Assertions
@@ -124,8 +179,8 @@ def test_evaluate_multiple_entries(groundtruth):
     assert overall_metrics.groundwater_metrics[0].filename == "example_borehole_profile.pdf"
     assert overall_metrics.groundwater_metrics[1].filename == "example_borehole_profile_2.pdf"
     assert overall_metrics.groundwater_metrics[0].groundwater_metrics.f1 == 1.0
-    assert overall_metrics.groundwater_metrics[1].groundwater_metrics.tp == 1.0
-    assert overall_metrics.groundwater_metrics[1].groundwater_metrics.fn == 1.0
+    assert overall_metrics.groundwater_metrics[1].groundwater_metrics.tp == 2.0
+    assert overall_metrics.groundwater_metrics[1].groundwater_metrics.fn == 0.0
     assert overall_metrics.groundwater_metrics[1].groundwater_metrics.fp == 0.0
 
 
diff --git a/tests/test_predictions.py b/tests/test_predictions.py
index 2aa96a21..c4516e7e 100644
--- a/tests/test_predictions.py
+++ b/tests/test_predictions.py
@@ -1,27 +1,33 @@
 """Test suite for the prediction module."""
 
 from datetime import datetime
-from pathlib import Path
 from unittest.mock import Mock
 
 import fitz
 import pytest
 from stratigraphy.benchmark.ground_truth import GroundTruth
 from stratigraphy.data_extractor.data_extractor import FeatureOnPage
+from stratigraphy.evaluation.layer_evaluator import LayerEvaluator
 from stratigraphy.evaluation.utility import count_against_ground_truth
-from stratigraphy.groundwater.groundwater_extraction import Groundwater, GroundwaterInDocument
-from stratigraphy.layer.layer import LayersInDocument
+from stratigraphy.groundwater.groundwater_extraction import Groundwater, GroundwatersInBorehole
+from stratigraphy.layer.layer import Layer, LayerDepths, LayerDepthsEntry, LayersInBorehole
 from stratigraphy.metadata.coordinate_extraction import CoordinateEntry, LV95Coordinate
-from stratigraphy.metadata.metadata import BoreholeMetadata
-from stratigraphy.util.predictions import FilePredictions, OverallFilePredictions
+from stratigraphy.metadata.metadata import BoreholeMetadata, FileMetadata
+from stratigraphy.text.textblock import MaterialDescription
+from stratigraphy.util.borehole_predictions import BoreholePredictionsWithGroundTruth, FilePredictionsWithGroundTruth
+from stratigraphy.util.file_predictions import FilePredictions
+from stratigraphy.util.overall_file_predictions import OverallFilePredictions
+from stratigraphy.util.predictions import AllBoreholePredictionsWithGroundTruth, BoreholePredictions
 
 
 @pytest.fixture
 def sample_file_prediction() -> FilePredictions:
     """Fixture to create a sample FilePredictions object."""
-    coord = LV95Coordinate(
-        east=CoordinateEntry(coordinate_value=2789456),
-        north=CoordinateEntry(coordinate_value=1123012),
+    filename = "example_borehole_profile.pdf"
+    coord = FeatureOnPage(
+        feature=LV95Coordinate(
+            east=CoordinateEntry(coordinate_value=2789456), north=CoordinateEntry(coordinate_value=1123012)
+        ),
         rect=fitz.Rect(),
         page=1,
     )
@@ -32,7 +38,7 @@ def sample_file_prediction() -> FilePredictions:
     layer2 = Mock(
         material_description=Mock(text="Clay"), depth_interval=Mock(start=Mock(value=30), end=Mock(value=50))
     )
-    layers_in_document = LayersInDocument(layers=[layer1, layer2], filename="test_file")
+    layers_in_borehole = LayersInBorehole(layers=[layer1, layer2])
 
     dt_date = datetime(2024, 10, 1)
     groundwater_on_page = FeatureOnPage(
@@ -40,16 +46,136 @@ def sample_file_prediction() -> FilePredictions:
         page=1,
         rect=fitz.Rect(0, 0, 100, 100),
     )
-    groundwater_in_doc = GroundwaterInDocument(groundwater=[groundwater_on_page], filename="test_file")
+    groundwater_in_bh = GroundwatersInBorehole(groundwater_feature_list=[groundwater_on_page])
 
-    metadata = BoreholeMetadata(coordinates=coord, page_dimensions=[Mock(width=10, height=20)], language="en")
+    file_metadata = FileMetadata(language="en", filename=filename, page_dimensions=[Mock(width=10, height=20)])
+    metadata = BoreholeMetadata(coordinates=coord, elevation=None)
 
     return FilePredictions(
-        layers_in_document=layers_in_document,
-        file_name="test_file",
-        metadata=metadata,
-        groundwater=groundwater_in_doc,
-        bounding_boxes=[],
+        [
+            BoreholePredictions(
+                borehole_index=0,
+                layers_in_borehole=layers_in_borehole,
+                file_name=filename,
+                metadata=metadata,
+                groundwater_in_borehole=groundwater_in_bh,
+                bounding_boxes=[],
+            )
+        ],
+        file_name=filename,
+        file_metadata=file_metadata,
+    )
+
+
+@pytest.fixture
+def file_prediction_with_two_boreholes() -> FilePredictions:
+    """Fixture to create a sample FilePredictions object that has two boreholes."""
+    filename = "example_borehole_profile.pdf"
+    coord = FeatureOnPage(
+        feature=LV95Coordinate(
+            east=CoordinateEntry(coordinate_value=2789456), north=CoordinateEntry(coordinate_value=1123012)
+        ),
+        rect=fitz.Rect(),
+        page=1,
+    )
+
+    layers_in_borehole = LayersInBorehole(
+        [
+            Layer(
+                material_description=FeatureOnPage(MaterialDescription(text=descr, lines=[]), fitz.Rect(), 0),
+                depths=LayerDepths(LayerDepthsEntry(start, fitz.Rect()), LayerDepthsEntry(end, fitz.Rect())),
+            )
+            for descr, start, end in [
+                ("HUMUS", None, 1),
+                ("KIES, grau", 1, 2),
+                ("sand", 2, 3),
+            ]
+        ]
+    )
+    layers_in_borehole_2 = LayersInBorehole(
+        [
+            Layer(
+                material_description=FeatureOnPage(MaterialDescription(text=descr, lines=[]), fitz.Rect(), 0),
+                depths=LayerDepths(LayerDepthsEntry(start, fitz.Rect()), LayerDepthsEntry(end, fitz.Rect())),
+            )
+            for descr, start, end in [
+                ("KIES, Sand,", 0.0, 0.5),
+                ("stein, sand", 0.5, 2.0),
+            ]
+        ]
+    )
+
+    dt_date = datetime(2024, 10, 1)
+    groundwater_on_page = FeatureOnPage(
+        feature=Groundwater(depth=100, date=dt_date, elevation=20),
+        page=1,
+        rect=fitz.Rect(0, 0, 100, 100),
+    )
+    groundwater_in_bh = GroundwatersInBorehole(groundwater_feature_list=[groundwater_on_page])
+
+    file_metadata = FileMetadata(language="en", filename=filename, page_dimensions=[Mock(width=10, height=20)])
+    metadata = BoreholeMetadata(coordinates=coord, elevation=None)
+
+    return FilePredictions(
+        [
+            BoreholePredictions(
+                borehole_index=0,
+                layers_in_borehole=layers_in_borehole,
+                file_name=filename,
+                metadata=metadata,
+                groundwater_in_borehole=groundwater_in_bh,
+                bounding_boxes=[],
+            ),
+            BoreholePredictions(
+                borehole_index=1,
+                layers_in_borehole=layers_in_borehole_2,
+                file_name=filename,
+                metadata=metadata,
+                groundwater_in_borehole=groundwater_in_bh,
+                bounding_boxes=[],
+            ),
+        ],
+        file_name=filename,
+        file_metadata=file_metadata,
+    )
+
+
+@pytest.fixture
+def groundtruth():
+    """Path to the ground truth file."""
+    return GroundTruth("example/example_groundtruth.json")
+
+
+@pytest.fixture
+def groundtruth_with_two_boreholes():
+    """Path to the ground truth file that has two boreholes."""
+    return GroundTruth("example/example_layers_groundtruth.json")
+
+
+@pytest.fixture
+def sample_file_prediction_with_ground_truth(
+    sample_file_prediction: FilePredictions, groundtruth: GroundTruth
+) -> FilePredictionsWithGroundTruth:
+    """Builds a FilePredictionsWithGroundTruth object with the given predictions and groundtruth.
+
+    Args:
+        sample_file_prediction (FilePredictions): a fixture that returns the FilePredictions object
+        groundtruth (GroundTruth): a fixture that returns the Groudtruth object
+
+    Returns:
+        FilePredictionsWithGroundTruth: the FilePredictionsWithGroundTruth associated
+    """
+    file_ground_truth: dict = groundtruth.for_file(sample_file_prediction.file_name)
+    gt_index = 0
+    return FilePredictionsWithGroundTruth(
+        filename=sample_file_prediction.file_name,
+        language=sample_file_prediction.file_metadata.language,
+        boreholes=[
+            BoreholePredictionsWithGroundTruth(
+                predictions=borehole_preds, ground_truth=file_ground_truth.get(gt_index)
+            )
+            for borehole_preds in sample_file_prediction.borehole_predictions_list
+        ],
     )
 
 
@@ -58,31 +184,43 @@ def test_to_json(sample_file_prediction: FilePredictions):
     result = sample_file_prediction.to_json()
 
     assert isinstance(result, dict)
-    assert len(result["layers"]) == 2
-    assert result["metadata"]["coordinates"]["E"] == 2789456
-    assert result["metadata"]["language"] == "en"
+    assert len(result["boreholes"][0]["layers"]) == 2
+    assert result["boreholes"][0]["metadata"]["coordinates"]["E"] == 2789456
+    assert result["language"] == "en"
 
 
-def test_overall_file_predictions():
+def test_overall_file_predictions(sample_file_prediction: FilePredictions):
     """Test OverallFilePredictions class functionality."""
     overall_predictions = OverallFilePredictions()
-    file_prediction = Mock(to_json=lambda: {"some_data": "test"}, file_name="test_file")
 
-    overall_predictions.add_file_predictions(file_prediction)
+    overall_predictions.add_file_predictions(sample_file_prediction)
     result = overall_predictions.to_json()
 
     assert len(result) == 1
-    assert result == {"test_file": {"some_data": "test"}}
+    assert set(result.keys()) == {"example_borehole_profile.pdf"}
 
 
-def test_evaluate_metadata_extraction():
-    """Test evaluate_metadata_extraction method of OverallFilePredictions."""
-    overall_predictions = OverallFilePredictions()
-    file_prediction = Mock(metadata=Mock(to_json=lambda: {"coordinates": "some_coordinates"}))
-    overall_predictions.add_file_predictions(file_prediction)
+def test_evaluate_layer_matching(
+    file_prediction_with_two_boreholes: FilePredictions, groundtruth_with_two_boreholes: GroundTruth
+):
+    """Test the matching of predictions to ground truths when multiple boreholes are present in one document."""
+    groundtruth_for_file = groundtruth_with_two_boreholes.for_file("example_borehole_profile.pdf")
+    sample_file_prediction_with_ground_truth: FilePredictionsWithGroundTruth = (
+        LayerEvaluator.match_predictions_with_ground_truth(file_prediction_with_two_boreholes, groundtruth_for_file)
+    )
+    # We test the matching by comparing the number of layers, one borehole has 2, the other has 3.
+    assert all(
+        [
+            len(pred.predictions.layers_in_borehole.layers) == len(pred.ground_truth["layers"])
+            for pred in sample_file_prediction_with_ground_truth
+        ]
+    )
 
-    ground_truth = GroundTruth(Path("example/example_groundtruth.json"))
-    metadata_metrics = overall_predictions.evaluate_metadata_extraction(ground_truth)
+
+def test_evaluate_metadata_extraction(sample_file_prediction_with_ground_truth: FilePredictionsWithGroundTruth):
+    """Test evaluate_metadata_extraction method of OverallFilePredictions."""
+    all_predictions_with_gt = AllBoreholePredictionsWithGroundTruth([sample_file_prediction_with_ground_truth])
+    metadata_metrics = all_predictions_with_gt.evaluate_metadata_extraction()
 
     assert metadata_metrics is not None  # Ensure the evaluation returns a result