diff --git a/.github/workflows/post-coverage.yml b/.github/workflows/post-coverage.yml new file mode 100644 index 0000000..6aab4b6 --- /dev/null +++ b/.github/workflows/post-coverage.yml @@ -0,0 +1,35 @@ +name: Post coverage report to PR + +on: + workflow_run: + workflows: ["Python test"] + types: + - completed + +permissions: + pull-requests: write + actions: read + +jobs: + comment: + runs-on: ubuntu-latest + if: > + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + steps: + - name: Download coverage artifact + uses: actions/download-artifact@v4 + with: + name: coverage-report + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Get PR number + id: pr_number + run: echo "number=$(cat pr_number.txt)" >> $GITHUB_OUTPUT + + - name: Post coverage report to PR + uses: marocchino/sticky-pull-request-comment@v2 + with: + path: cov_report.txt + number: ${{ steps.pr_number.outputs.number }} diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 3c0bbdc..3d3805f 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -20,7 +20,6 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: - ref: ${{ github.head_ref }} fetch-depth: 0 - name: Install uv, set the python version, and enable cache @@ -40,11 +39,19 @@ jobs: coverage report -m --format markdown > cov_report.txt coverage xml - - name: Post coverage report to PR + - name: Save PR number if: matrix.python-version == '3.11' && matrix.os == 'ubuntu-latest' - uses: marocchino/sticky-pull-request-comment@v2 + run: echo ${{ github.event.number }} > pr_number.txt + + - name: Save coverage report and PR number + if: matrix.python-version == '3.11' && matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v4 with: - path: cov_report.txt + name: coverage-report + path: | + cov_report.txt + pr_number.txt + retention-days: 1 - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4.0.1 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 6de56c5..4c8e3bc 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -max.pargmann@dlr.de. +artist@lists.kit.edu. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/README.md b/README.md index 5887090..82460be 100644 --- a/README.md +++ b/README.md @@ -71,23 +71,27 @@ The ``PAINT`` repository is structured as shown below: . ├── html # Code for the paint-database.org website ├── markers # Saved markers for the WRI1030197 power plant in Jülich -├── paint # Python package +├── paint # Python package/ │ ├── data │ ├── preprocessing │ └── util ├── plots # Scripts used to generate plots found in our paper ├── preprocessing-scripts # Scripts used for preprocessing and STAC generation ├── scripts # Scripts highlighting example usage of the data -└── test # Tests for the python package - ├── data - ├── preprocessing - └── util +├── test # Tests for the python package/ +│ ├── data +│ ├── preprocessing +│ └── util +└── tutorials # Interactive notebooks showcasing how to get started with PAINT ``` ### Example usage: In the ``scripts`` folder there are multiple scripts highlighting how ``PAINT`` can be used. Detailed descriptions of these scripts are available via our [Documentation](http://paint.readthedocs.io). +Furthermore, an interactive notebook is available in the ``tutorials`` folder - this is the perfect starting point to +dive into ``PAINT``! + ## How to contribute Check out our [contribution guidelines](CONTRIBUTING.md) if you are interested in contributing to the `PAINT` project :fire:. Please also carefully check our [code of conduct](CODE_OF_CONDUCT.md) :blue_heart:. diff --git a/SECURITY.md b/SECURITY.md index 55a3284..529845b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,10 +2,15 @@ ## Supported Versions -We are currently supporting ``PAINT 1.0.0`` +We are currently supporting ``PAINT 2.0.1`` | Version | Supported | -| ------- | ------------------ | +|---------| ------------------ | +| 2.0.1 | :white_check_mark: | +| 2.0.0 | :white_check_mark: | +| 1.0.3 | :white_check_mark: | +| 1.0.2 | :white_check_mark: | +| 1.0.1 | :white_check_mark: | | 1.0.0 | :white_check_mark: | ## Reporting a Vulnerability diff --git a/docs/conf.py b/docs/conf.py index 5c7ae1d..10181d5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,7 +10,7 @@ project = "PAINT" copyright = f"{datetime.now().year}, ARTIST consortium" author = "ARTIST Consortium" -release = "2.0.0" +release = "2.0.1" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/docs/dataset.rst b/docs/dataset.rst index f145c33..1324bd2 100644 --- a/docs/dataset.rst +++ b/docs/dataset.rst @@ -32,7 +32,7 @@ There are three ways of creating a ``PaintCalibrationDataset``: 2. **From a benchmark file** - You can also create the dataset from a benchmark file (see above). In this case, the ``benchmark_file`` must be provided: + You can also create the dataset from a benchmark file (see the :information on dataset splits:`splitter` for details). In this case, the ``benchmark_file`` containing information on the train, validation, and test split must be provided: .. code-block:: python diff --git a/docs/splitter.rst b/docs/splitter.rst index 27bb5e8..5190fbc 100644 --- a/docs/splitter.rst +++ b/docs/splitter.rst @@ -34,7 +34,7 @@ Supported Splits Again, the goal is to create diverse and challenging training and validation datasets. - **Balanced Split:** - This method uses KMeans clustering on azimuth and elevation features to ensure a stratified selection. The process includes: + This method uses k-means clustering on azimuth and elevation features to ensure a stratified selection. The process includes: - Clustering the data into ``validation_size`` clusters. - Selecting one data point per cluster for the validation split. @@ -76,3 +76,5 @@ To generate the splits, simply call the ``get_dataset_splits()`` function: azimuth_splits = splitter.get_dataset_splits( split_type="azimuth", training_size=10, validation_size=30 ) + +This returns a ``pd.Dataframe`` containing information on the splits, i.e. which samples belong to which split, and also saves this information as a CSV file. diff --git a/docs/usage.rst b/docs/usage.rst index 77f8b42..873699e 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -2,7 +2,23 @@ How To Use ========== -Here, you can find an overview of how to use ``PAINT``. + +To get started with ``PAINT`` we have included a interactive notebook, which is available here: https://github.com/ARTIST-Association/PAINT/blob/main/tutorials/paint_data_tutorial.ipynb. + +This tutorial provides an interactive introduction to the PAINT database, demonstrating how to: +- Initialize the STAC client. +- Download and inspect metadata. +- Generate calibration data splits. +- Load calibration data using a dataloader. +- Download and inspect other types of PAINT data. + +To run the tutorial make sure you install the tutorial dependencies, i.e.: + +.. code-block:: console + + $ pip install "paint-csp[tutorial]" + +Most of the concepts covered in the interactive tutorial are also covered in the documentation and associated scripts listed below: .. toctree:: :maxdepth: 1 diff --git a/paint/__init__.py b/paint/__init__.py index a9d1c74..33c0184 100644 --- a/paint/__init__.py +++ b/paint/__init__.py @@ -1,6 +1,12 @@ import os +from importlib.metadata import PackageNotFoundError, version PAINT_ROOT = f"{os.sep}".join(__file__.split(os.sep)[:-2]) """Reference to the root directory of ARTIST.""" +try: + __version__ = version("paint-csp") +except PackageNotFoundError: + # Allows running from source without installation. + __version__ = "0.0.0" -__all__ = ["PAINT_ROOT", "preprocessing", "util"] +__all__ = ["PAINT_ROOT", "preprocessing", "util", "__version__"] diff --git a/paint/data/dataset.py b/paint/data/dataset.py index 964dcdb..fe11e6a 100644 --- a/paint/data/dataset.py +++ b/paint/data/dataset.py @@ -142,7 +142,7 @@ def _check_accepted_keys(key: str) -> None: @classmethod def from_benchmark( cls, - benchmark_file: str | Path, + benchmark_file: str | Path | pd.DataFrame, root_dir: str | Path, item_type: str, download: bool = False, @@ -157,8 +157,8 @@ def from_benchmark( Parameters ---------- - benchmark_file : str | Path - Path to the file containing the benchmark information. + benchmark_file : str | Path | pd.DataFrame + Path to the file containing the benchmark information, or dataframe containing this information. root_dir : str | Path Directory where the dataset will be stored. item_type : str @@ -182,12 +182,29 @@ def from_benchmark( Validation dataset. """ root_dir = Path(root_dir) - log.info( - f"Begining the process of generating benchmark datasets. The file used to generate the benchmarks is:\n" - f" {benchmark_file}!" - ) - # Load the splits data. - splits = pd.read_csv(benchmark_file) + if not isinstance(benchmark_file, pd.DataFrame): + log.info( + f"Begining the process of generating benchmark datasets. The file used to generate the benchmarks is:\n" + f" {benchmark_file}!" + ) + # Load the splits data. + splits = pd.read_csv(benchmark_file) + else: + log.info( + "Begining the process of generating benchmark datasets using provided pandas dataframe!" + ) + benchmark_file.reset_index(inplace=True) + splits = benchmark_file + + expected_cols = ["Id", "HeliostatId", "Split"] + try: + pd.testing.assert_index_equal(splits.columns, pd.Index(expected_cols)) + except AssertionError as e: + raise ValueError( + f"The dataset split file provide has an incorrect schema. Please verify and try again.\n" + f"Expected: {expected_cols}\n" + f"Details: {e}" + ) # Check whether to download the data or not. if download: # pragma: no cover diff --git a/paint/data/dataset_splits.py b/paint/data/dataset_splits.py index 208b30a..2436ef8 100644 --- a/paint/data/dataset_splits.py +++ b/paint/data/dataset_splits.py @@ -457,6 +457,11 @@ def get_dataset_splits( Size of the training split. validation_size : int Size of the validation split. + + Returns + ------- + pd.DataFrame + Dataframe containing information on the dataset splits. """ allowed_split_types = [ mappings.AZIMUTH_SPLIT, diff --git a/paint/data/stac_client.py b/paint/data/stac_client.py index ccce994..3686179 100644 --- a/paint/data/stac_client.py +++ b/paint/data/stac_client.py @@ -69,6 +69,7 @@ def __init__( self.output_dir = pathlib.Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) self.chunk_size = chunk_size + log.info(f"Initializing STAC client to download data to: {output_dir}.") @staticmethod def load_checkpoint(path: pathlib.Path) -> dict[str, Any]: @@ -715,7 +716,7 @@ def get_heliostat_data( # Download the data for each heliostat. for heliostat_catalog in heliostat_catalogs_list: log.info(f"Processing heliostat catalog {heliostat_catalog.id}") - success = False + success = True # Download calibration data. if get_calibration: diff --git a/plots/04_create_distribution_plots.py b/plots/04_create_distribution_plots.py index 744d54e..32dbf91 100644 --- a/plots/04_create_distribution_plots.py +++ b/plots/04_create_distribution_plots.py @@ -79,9 +79,8 @@ def __init__( self.output_path.mkdir(parents=True, exist_ok=True) self.figure_size = (4, 4) - self.data = self._load_data() - # Power plant position as tensor + # Power plant position as tensor. power_plant_lat, power_plant_lon = convert_gk_to_lat_lon( mappings.GK_RIGHT_BASE, mappings.GK_HEIGHT_BASE ) @@ -92,7 +91,11 @@ def __init__( mappings.POWER_PLANT_ALT, ] ) - # Precompute receiver corners once + + # Load data. + self.data = self._load_data() + + # Precompute receiver corners once. self.receiver_coordinates = [ convert_wgs84_coordinates_to_local_enu( torch.tensor(coords), self.power_plant_position diff --git a/pyproject.toml b/pyproject.toml index 82b4071..0ea95ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ packages = ["paint"] [project] name = "paint-csp" -version = "2.0.0" +version = "2.0.1" authors = [ { name="ARTIST Consortium", email="artist@lists.kit.edu" }, ] @@ -17,7 +17,7 @@ requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", - "Development Status :: 1 - Planning", + "Development Status :: 5 - Production/Stable", ] dependencies = [ "numpy", @@ -51,6 +51,7 @@ dev = [ "sphinxcontrib-napoleon", "sphinxemoji" ] +tutorial = ["jupyter"] [project.urls] Homepage = "https://github.com/ARTIST-Association/PAINT" diff --git a/tests/data/test_dataset.py b/tests/data/test_dataset.py index c5b9c30..4892b20 100644 --- a/tests/data/test_dataset.py +++ b/tests/data/test_dataset.py @@ -4,6 +4,7 @@ import cv2 import deepdiff +import pandas as pd import pytest import torch from torchvision import transforms @@ -191,6 +192,25 @@ def test_from_benchmark( assert len(test) == 4 assert len(val) == 3 + # Test with Pandas dataframe as input instead of file. + benchmark_df = pd.read_csv( + pathlib.Path(PAINT_ROOT) + / "tests" + / "data" + / "test_data" + / "test_benchmark.csv", + index_col=0, + ) + train, test, val = PaintCalibrationDataset.from_benchmark( + benchmark_file=benchmark_df, + root_dir=pathlib.Path(PAINT_ROOT) / "tests" / "data" / "test_data" / "dataset", + item_type=item_type, + download=download, + ) + assert len(train) == 3 + assert len(test) == 4 + assert len(val) == 3 + @pytest.mark.parametrize( "item_type, heliostats", @@ -284,3 +304,24 @@ def test_str_method() -> None: "-The dataset contains 4 items\n" ) assert str(dataset) == expected + + +def test_from_benchmark_fails_with_incorrect_dataframe( + tmp_path: pathlib.Path, +) -> None: + """ + Verify that ``from_benchmark`` raises ``ValueError`` when the input dataframe has incorrect columns. + + Parameters + ---------- + tmp_path : pathlib.Path + Fixture to the temporary folder. + """ + # Create invalid data frame. + invalid_df = pd.DataFrame(columns=["Id", "HeliostatId", "WrongCol"]) + + # Expect a ValueError. + with pytest.raises(ValueError, match="incorrect schema"): + PaintCalibrationDataset.from_benchmark( + benchmark_file=invalid_df, root_dir=tmp_path, item_type="raw_image" + ) diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..fa309df --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,33 @@ +import importlib +import importlib.metadata +from importlib.metadata import PackageNotFoundError +from unittest.mock import MagicMock + +import pytest + +import paint + + +def test_version_fallback_when_package_missing(monkeypatch: pytest.MonkeyPatch) -> None: + """ + Verify that ``__version__`` falls back to '0.0.0' if the package is not installed. + + This test mocks ``importlib.metadata.version`` to raise ``PackageNotFoundError``, + then reloads the module to trigger the except block. + + Parameters + ---------- + monkeypatch : pytest.MonkeyPatch + MonkeyPatch fixture. + """ + # Create a mock that raises the specific error. + mock_raiser = MagicMock(side_effect=PackageNotFoundError) + + # Apply the mock to the standard library function. + monkeypatch.setattr(importlib.metadata, "version", mock_raiser) + + # Reload the module to force the top-level try/except block to run again. + importlib.reload(paint) + + # Assert the fallback behavior. + assert paint.__version__ == "0.0.0" diff --git a/tutorials/paint_data_tutorial.ipynb b/tutorials/paint_data_tutorial.ipynb new file mode 100644 index 0000000..20af578 --- /dev/null +++ b/tutorials/paint_data_tutorial.ipynb @@ -0,0 +1,1909 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "23d8c4e94a4b55f4", + "metadata": {}, + "source": [ + "# ``PAINT`` Data Tutorial\n", + "\n", + "This interactive notebook provides a brief overview of the ``PAINT`` database, demonstrating how to:\n", + "- Initialize the STAC client.\n", + "- Download and inspect metadata.\n", + "- Generate calibration data splits.\n", + "- Load calibration data using a dataloader.\n", + "- Download and inspect other types of ``PAINT`` data.\n", + "\n", + "> **Note:** Python executable scripts for each step are available in the ``scripts`` folder of the [PAINT GitHub](https://github.com/ARTIST-Association/PAINT/tree/main/scripts). We recommend using those scripts if you plan to download and process large amounts of ``PAINT`` data." + ] + }, + { + "cell_type": "markdown", + "id": "3e82d5e92da63968", + "metadata": {}, + "source": [ + "## Getting Started\n", + "\n", + "To run this tutorial, ensure you have the ``PAINT`` tutorial dependencies installed:\n", + "```\n", + "pip install \"paint-csp[tutorial]\"\n", + "```\n", + "To verify the installation, let's import ``PAINT`` and check the version attribute:" + ] + }, + { + "cell_type": "code", + "id": "initial_id", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:12:26.352122Z", + "start_time": "2026-01-30T11:12:26.344571Z" + } + }, + "source": [ + "import paint\n", + "\n", + "print(f\"``PAINT`` is running with version: {paint.__version__}\")" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "``PAINT`` is running with version: 2.0.0\n" + ] + } + ], + "execution_count": 1 + }, + { + "cell_type": "markdown", + "id": "b2a2d5be158a05b9", + "metadata": {}, + "source": [ + "We also need to specify a directory where all downloaded data will be saved. **Update the file path below to a location that works for your system:**" + ] + }, + { + "cell_type": "code", + "id": "ee4635a10ae20007", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:12:26.361699Z", + "start_time": "2026-01-30T11:12:26.359796Z" + } + }, + "source": [ + "from pathlib import Path\n", + "\n", + "download_path = Path(\"./PAINT_tutorial_data\")" + ], + "outputs": [], + "execution_count": 2 + }, + { + "cell_type": "markdown", + "id": "57e39a37b0d41383", + "metadata": {}, + "source": [ + "## Downloading Metadata\n", + "\n", + "Before working with the actual ``PAINT`` data, we will inspect the metadata to understand what is available. For this tutorial, we will focus on a small subset of heliostats: those with IDs starting with \"AA\". This includes the range from **AA23 to AA51**.\n", + "\n", + "In the next step, we will:\n", + "- Generate a list of heliostats to access.\n", + "- Create a STAC client.\n", + "- Download the metadata and save it to the specified location." + ] + }, + { + "cell_type": "code", + "id": "95276b7d0af455cc", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:13:16.335654Z", + "start_time": "2026-01-30T11:12:26.445975Z" + } + }, + "source": [ + "# Import the STAC client.\n", + "from paint.data import StacClient\n", + "\n", + "# Generate heliostat list.\n", + "heliostat_list = [f\"AA{i}\" for i in range(23, 52)]\n", + "\n", + "# Initialize STAC client.\n", + "client = StacClient(output_dir=download_path)\n", + "\n", + "# Download metadata.\n", + "client.get_heliostat_metadata(heliostats=heliostat_list)" + ], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "No collections selected - downloading data for all collections!\n", + "Processing Heliostat Catalogs: 0%| | 0/29 [00:00\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
IdHeliostatIdlatitudelongitudeElevationDateTime
0AA23-heliostat-propertiesAA2350.9136476.38701288.5900572021-07-20 05:09:00+00:00
1AA24-heliostat-propertiesAA2450.9136466.38707588.5998082021-07-20 05:09:00+00:00
2AA25-heliostat-propertiesAA2550.9136466.38713888.6205982021-07-20 05:09:00+00:00
3AA26-heliostat-propertiesAA2650.9136466.38720088.6030582021-07-20 05:09:00+00:00
4AA27-heliostat-propertiesAA2750.9136466.38726388.6156542021-07-20 05:09:00+00:00
\n", + "" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 5 + }, + { + "cell_type": "markdown", + "id": "3b7c1e4028dc1cf9", + "metadata": {}, + "source": [ + "Above we can see the first five rows of this metadata table. Now lets look at the calibration metadata:" + ] + }, + { + "cell_type": "code", + "id": "e6ef7037a3e832e0", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:13:16.426869Z", + "start_time": "2026-01-30T11:13:16.425059Z" + } + }, + "source": [ + "# Inspect the calibration metadata.\n", + "print(\n", + " f\"The calibration metadata file contains {len(calibration_metadata)} rows and {len(calibration_metadata.columns)} columns.\\n\"\n", + " f\"The columns are: {', '.join(calibration_metadata.columns)}\"\n", + ")" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The calibration metadata file contains 4691 rows and 17 columns.\n", + "The columns are: Id, HeliostatId, Azimuth, Elevation, lower_left_latitude, lower_left_longitude, lower_left_Elevation, upper_left_latitude, upper_left_longitude, upper_left_Elevation, upper_right_latitude, upper_right_longitude, upper_right_Elevation, lower_right_latitude, lower_right_longitude, lower_right_Elevation, DateTime\n" + ] + } + ], + "execution_count": 6 + }, + { + "cell_type": "markdown", + "id": "e21629a42491489b", + "metadata": {}, + "source": [ + "This dataframe contains significantly more rows because there are often multiple calibration measurements for each heliostat. The columns include:\n", + "- **Id:** The measurement ID of the calibration measurement\n", + "- **HeliostatId:** The ID of the heliostat used for this measurement\n", + "- **Azimuth:** The sun's azimuth at the time of measurement\n", + "- **Elevation:** The sun's elevation at the time of measurement\n", + "- **Target Coordinates:** The latitude, longitude, and elevation for the *lower_left*, *upper_left*, *upper_right*, and *lower_right* corners of the calibration target\n", + "- **DateTime:** The timestamp of the measurement\n", + "\n", + "The first five rows are displayed below:" + ] + }, + { + "cell_type": "code", + "id": "cdf7447636c43830", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:13:16.561059Z", + "start_time": "2026-01-30T11:13:16.555687Z" + } + }, + "source": [ + "calibration_metadata.head()" + ], + "outputs": [ + { + "data": { + "text/plain": [ + " Id HeliostatId Azimuth Elevation lower_left_latitude \\\n", + "0 225295 AA23 81.839158 37.047879 50.913396 \n", + "1 199617 AA23 -24.275629 48.834090 50.913396 \n", + "2 62302 AA23 -42.017068 8.527271 50.913396 \n", + "3 222963 AA23 -6.400352 62.327916 50.913392 \n", + "4 212358 AA23 66.411607 45.213617 50.913392 \n", + "\n", + " lower_left_longitude lower_left_Elevation upper_left_latitude \\\n", + "0 6.387613 135.789 50.913396 \n", + "1 6.387613 135.789 50.913396 \n", + "2 6.387613 135.789 50.913396 \n", + "3 6.387886 119.268 50.913392 \n", + "4 6.387886 119.268 50.913392 \n", + "\n", + " upper_left_longitude upper_left_Elevation upper_right_latitude \\\n", + "0 6.387613 142.175 50.913397 \n", + "1 6.387613 142.175 50.913397 \n", + "2 6.387613 142.175 50.913397 \n", + "3 6.387886 126.470 50.913392 \n", + "4 6.387886 126.470 50.913392 \n", + "\n", + " upper_right_longitude upper_right_Elevation lower_right_latitude \\\n", + "0 6.387536 142.172 50.913397 \n", + "1 6.387536 142.172 50.913397 \n", + "2 6.387536 142.172 50.913397 \n", + "3 6.387763 126.506 50.913392 \n", + "4 6.387763 126.506 50.913392 \n", + "\n", + " lower_right_longitude lower_right_Elevation DateTime \n", + "0 6.387536 135.783 2023-06-27 05:39:56+00:00 \n", + "1 6.387536 135.783 2023-04-21 10:37:26+00:00 \n", + "2 6.387536 135.783 2022-01-18 13:44:45+00:00 \n", + "3 6.387763 119.279 2023-06-16 09:48:04+00:00 \n", + "4 6.387763 119.279 2023-05-31 06:35:41+00:00 " + ], + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
IdHeliostatIdAzimuthElevationlower_left_latitudelower_left_longitudelower_left_Elevationupper_left_latitudeupper_left_longitudeupper_left_Elevationupper_right_latitudeupper_right_longitudeupper_right_Elevationlower_right_latitudelower_right_longitudelower_right_ElevationDateTime
0225295AA2381.83915837.04787950.9133966.387613135.78950.9133966.387613142.17550.9133976.387536142.17250.9133976.387536135.7832023-06-27 05:39:56+00:00
1199617AA23-24.27562948.83409050.9133966.387613135.78950.9133966.387613142.17550.9133976.387536142.17250.9133976.387536135.7832023-04-21 10:37:26+00:00
262302AA23-42.0170688.52727150.9133966.387613135.78950.9133966.387613142.17550.9133976.387536142.17250.9133976.387536135.7832022-01-18 13:44:45+00:00
3222963AA23-6.40035262.32791650.9133926.387886119.26850.9133926.387886126.47050.9133926.387763126.50650.9133926.387763119.2792023-06-16 09:48:04+00:00
4212358AA2366.41160745.21361750.9133926.387886119.26850.9133926.387886126.47050.9133926.387763126.50650.9133926.387763119.2792023-05-31 06:35:41+00:00
\n", + "
" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 7 + }, + { + "cell_type": "markdown", + "id": "e00b86252619e38e", + "metadata": {}, + "source": [ + "Now finally it is time to inspect the deflectometry metadata:" + ] + }, + { + "cell_type": "code", + "id": "55674ae61e53e85", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:13:16.621784Z", + "start_time": "2026-01-30T11:13:16.620017Z" + } + }, + "source": [ + "# Inspect the deflectometry metadata.\n", + "print(\n", + " f\"The deflectometry metadata file contains {len(deflectometry_metadata)} rows and {len(deflectometry_metadata.columns)} columns.\\n\"\n", + " f\"The columns are: {', '.join(deflectometry_metadata.columns)}\"\n", + ")" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The deflectometry metadata file contains 39 rows and 6 columns.\n", + "The columns are: Id, HeliostatId, latitude, longitude, Elevation, DateTime\n" + ] + } + ], + "execution_count": 8 + }, + { + "cell_type": "markdown", + "id": "9d8b2f0fd819e366", + "metadata": {}, + "source": [ + "Again, we see more rows than the number of heliostats because some heliostats contain multiple deflectometry measurements. The columns are nearly identical to the properties metadata, with one key difference: the **Id** column refers to the *deflectometry STAC ID*, not the properties ID.\n", + "\n", + "The first five rows are displayed below:" + ] + }, + { + "cell_type": "code", + "id": "f4a270d14e53662a", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:13:16.679975Z", + "start_time": "2026-01-30T11:13:16.676206Z" + } + }, + "source": [ + "deflectometry_metadata.head()" + ], + "outputs": [ + { + "data": { + "text/plain": [ + " Id HeliostatId latitude longitude \\\n", + "0 AA23-2021-10-13Z09-27-07Z-deflectometry AA23 50.913647 6.387012 \n", + "1 AA24-2021-10-13Z09-29-29Z-deflectometry AA24 50.913646 6.387075 \n", + "2 AA25-2021-10-13Z09-32-36Z-deflectometry AA25 50.913646 6.387138 \n", + "3 AA26-2021-10-13Z09-34-21Z-deflectometry AA26 50.913646 6.387200 \n", + "4 AA27-2021-10-12Z13-27-32Z-deflectometry AA27 50.913646 6.387263 \n", + "\n", + " Elevation DateTime \n", + "0 88.590057 2021-10-13 09:27:07+00:00 \n", + "1 88.599808 2021-10-13 09:29:29+00:00 \n", + "2 88.620598 2021-10-13 09:32:36+00:00 \n", + "3 88.603058 2021-10-13 09:34:21+00:00 \n", + "4 88.615654 2021-10-12 13:27:32+00:00 " + ], + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
IdHeliostatIdlatitudelongitudeElevationDateTime
0AA23-2021-10-13Z09-27-07Z-deflectometryAA2350.9136476.38701288.5900572021-10-13 09:27:07+00:00
1AA24-2021-10-13Z09-29-29Z-deflectometryAA2450.9136466.38707588.5998082021-10-13 09:29:29+00:00
2AA25-2021-10-13Z09-32-36Z-deflectometryAA2550.9136466.38713888.6205982021-10-13 09:32:36+00:00
3AA26-2021-10-13Z09-34-21Z-deflectometryAA2650.9136466.38720088.6030582021-10-13 09:34:21+00:00
4AA27-2021-10-12Z13-27-32Z-deflectometryAA2750.9136466.38726388.6156542021-10-12 13:27:32+00:00
\n", + "
" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 9 + }, + { + "cell_type": "markdown", + "id": "cb071d62c5c816ea", + "metadata": {}, + "source": [ + "Since we will be using the calibration dataset later, let's inspect it in more detail. Specifically, we look at how many of our heliostats have calibration measurements and how the number of calibration measurements varies across the heliostats:" + ] + }, + { + "cell_type": "code", + "id": "ae5fafd83c0957e0", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:13:16.727793Z", + "start_time": "2026-01-30T11:13:16.722279Z" + } + }, + "source": [ + "from IPython.display import HTML, display\n", + "\n", + "# Calculate counts once.\n", + "counts = calibration_metadata[\"HeliostatId\"].value_counts()\n", + "unique_heliostats = calibration_metadata[\"HeliostatId\"].nunique()\n", + "\n", + "# Create DataFrames for better rendering.\n", + "top_5 = counts.head(5).to_frame(name=\"Measurement Count\")\n", + "bottom_5 = counts.tail(5).to_frame(name=\"Measurement Count\")\n", + "\n", + "display(\n", + " HTML(f\"\"\"\n", + "

Unique Heliostats: {unique_heliostats}

\n", + "
\n", + "
\n", + " Top 5 (Most Measurements)\n", + " {top_5.to_html()}\n", + "
\n", + "
\n", + " Bottom 5 (Least Measurements)\n", + " {bottom_5.to_html()}\n", + "
\n", + "
\n", + "\"\"\")\n", + ")" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "" + ], + "text/html": [ + "\n", + "

Unique Heliostats: 27

\n", + "
\n", + "
\n", + " Top 5 (Most Measurements)\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Measurement Count
HeliostatId
AA23262
AA24228
AA51223
AA45215
AA49214
\n", + "
\n", + "
\n", + " Bottom 5 (Least Measurements)\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Measurement Count
HeliostatId
AA42140
AA39139
AA31135
AA4195
AA431
\n", + "
\n", + "
\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "execution_count": 10 + }, + { + "cell_type": "markdown", + "id": "b0d59ca652a6474c", + "metadata": {}, + "source": [ + "**Key takeaways for the next steps:**\n", + "- We have 27 heliostats with calibration measurements.\n", + "- One heliostat (AA43) has only a single calibration measurement; the rest has at least 95 each." + ] + }, + { + "cell_type": "markdown", + "id": "5f668d8bbd3cbe87", + "metadata": {}, + "source": [ + "## Creating a Calibration Dataset Split from the Metadata\n", + "\n", + "Now that we have inspected the metadata, we can create a dataset split for the calibration data. To summarize the nature of calibration data:\n", + "- Heliostats often have unknown offsets and deformations, which cause deviations from the intended pointing direction.\n", + "- Power plant operators use *calibration targets* to capture photos of the flux image generated by a single heliostat. These images help determine pointing errors.\n", + "- Multiple measurements are taken across different times and seasons to assist with operations.\n", + "\n", + "This data is ideal for machine learning. We can use these images to train algorithms that improve power plant operation.\n", + "\n", + "For such algorithms, training, validation, and test splits are required. ``PAINT`` provides multiple splitting methods, including the *Azimuth Split*, *Solstice Split*, *Balanced Split*, and *High-Variance Split* (see the [documentation here](https://paint.readthedocs.io/en/latest/splitter.html)).\n", + "\n", + "In this tutorial, we will use the **Balanced Split**, which uses k-means clustering on the azimuth and elevation features to ensure a stratified selection:\n", + " - Data is clustered into ``validation_size`` clusters.\n", + " - One data point per cluster is selected for the **validation** subset.\n", + " - A distinct point from the same cluster is selected for the **test** subset (if possible).\n", + " - Missing test samples are filled from the overall pool to maintain balance.\n", + " - Remaining data points are assigned to the **training** subset." + ] + }, + { + "cell_type": "code", + "id": "c9bed2babdbf9116", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:13:19.261389Z", + "start_time": "2026-01-30T11:13:16.985615Z" + } + }, + "source": [ + "# Import paint mappings and dataset splitter.\n", + "import paint.util.paint_mappings as mappings\n", + "from paint.data.dataset_splits import DatasetSplitter\n", + "\n", + "# Set train and validation size.\n", + "training_size = 25\n", + "validation_size = 5\n", + "\n", + "# Create the dataset splitter.\n", + "splitter = DatasetSplitter(\n", + " input_file=calibration_metadata_file, output_dir=download_path\n", + ")\n", + "\n", + "# Perform the balanced split.\n", + "split_data = splitter.get_dataset_splits(\n", + " split_type=mappings.BALANCED_SPLIT,\n", + " training_size=training_size,\n", + " validation_size=validation_size,\n", + ")\n", + "\n", + "# Inspect the size of the splits.\n", + "split_data.Split.value_counts()" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "Split\n", + "train 650\n", + "validation 130\n", + "test 130\n", + "Name: count, dtype: int64" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 11 + }, + { + "cell_type": "markdown", + "id": "64a207a4d996fcbd", + "metadata": {}, + "source": [ + "**Verifying the Split**\n", + "\n", + "Does this output align with our earlier analysis?\n", + "- We have 27 heliostats with calibration measurements, but one (AA43) had only a single measurement.\n", + "- Because our validation size is 5, any heliostat with fewer than 5 measurements is excluded.\n", + "- This leaves 26 heliostats. With a validation size of 5 (and a matching test size of 5), we expect: $26 \\times 5 = 130$ samples for both validation and test sets.\n", + "- With a training size of 25, we expect: $25 \\times 26 = 650$ training samples.\n", + "\n", + "The numbers match! The split data has been automatically saved as a CSV in your download path (e.g., `benchmark_split-balanced_train-25_validation-5.csv`)." + ] + }, + { + "cell_type": "markdown", + "id": "95c3a3b0a33e56c8", + "metadata": {}, + "source": [ + "## Creating a Dataset\n", + "\n", + "Now that we have defined our splits, we can use the built-in ``PAINT`` functionality to create a ``torch.Dataset``. While there are several ways to create datasets (see [this tutorial](https://paint.readthedocs.io/en/latest/dataset.html)), we will use the benchmark split data we just generated.\n", + "\n", + "We must specify:\n", + "- The benchmark split file\n", + "- The root directory for downloads\n", + "- The item type\n", + "- Whether to download the data\n", + "\n", + "In this case, we use calibration images that have been cropped and centered on the **flux center of mass**. These are compressed and pre-processed, ensuring faster download times for this tutorial." + ] + }, + { + "cell_type": "code", + "id": "d57ec839e47c7668", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:13:39.430908Z", + "start_time": "2026-01-30T11:13:19.306597Z" + } + }, + "source": [ + "from paint.data.dataset import PaintCalibrationDataset\n", + "\n", + "# Initialize dataset from benchmark splits.\n", + "train, test, val = PaintCalibrationDataset.from_benchmark(\n", + " benchmark_file=split_data,\n", + " root_dir=download_path,\n", + " item_type=mappings.CALIBRATION_FLUX_CENTERED_IMAGE_KEY,\n", + " download=True,\n", + ")" + ], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Downloading benchmark data for the test split: 100%|██████████| 130/130 [00:02<00:00, 45.69Item/s]\n", + "Downloading benchmark data for the train split: 100%|██████████| 650/650 [00:12<00:00, 51.56Item/s]\n", + "Downloading benchmark data for the validation split: 100%|██████████| 130/130 [00:02<00:00, 49.34Item/s]\n" + ] + } + ], + "execution_count": 12 + }, + { + "cell_type": "markdown", + "id": "e9e1c60eccb6840e", + "metadata": {}, + "source": [ + "This results in a custom dataset that implements the standard PyTorch ``__getitem__()`` method. We can easily access the data (stored as tensors) for machine learning applications.\n", + "\n", + "Below is an example of loading and plotting the first four measurements from the training dataset:" + ] + }, + { + "cell_type": "code", + "id": "cdde5d097426998f", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:13:39.871761Z", + "start_time": "2026-01-30T11:13:39.465263Z" + } + }, + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "# Create a 2x2 grid.\n", + "fig, axes = plt.subplots(2, 2, figsize=(10, 10))\n", + "\n", + "for i in range(4):\n", + " # Determine the row and column index.\n", + " ax = axes[i // 2, i % 2]\n", + "\n", + " # Grab the i-th item from your dataset.\n", + " item = train[i]\n", + "\n", + " # Convert (Channel, Height, Width) -> (Height, Width, Channel) for plotting.\n", + " img_data = item.permute(1, 2, 0).detach().cpu().numpy()\n", + "\n", + " ax.imshow(img_data)\n", + " ax.set_title(f\"Train Index: {i}\")\n", + " ax.axis(\"off\")\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA8YAAAPdCAYAAABIgHGZAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy80BEi2AAAACXBIWXMAAA9hAAAPYQGoP6dpAABnZElEQVR4nO3de6xvd1kn/ue09NwvLaVUcBCwQUGUECeMpA5U1IhIJKMiItOkF8pggBDQGG8dQDSKlzhoTGOqU6somQmX2Cg4DhYIc3M0djoqIyqCzugYpC09t73POaXdvz+cnl+P7Xqv0+fbtfc+5/N6JU169rPXWp91+a7Pes63Xe8dGxsbGwUAAACDumCrBwAAAABbSWMMAADA0DTGAAAADE1jDAAAwNA0xgAAAAxNYwwAAMDQNMYAAAAMTWMMAADA0DTGAAAADE1jDE3XXnttPe1pT9vqYczasWNHve1tb9vqYQDAljBfA2dDY8x5Z8eOHWf1z0c/+tGtHuoZPvrRj9aOHTvqve9971YPZVH33ntv/at/9a/qsssuq3379tWLXvSiuuOOO7Z6WABsMvP19vV3f/d39f3f//31ohe9qA4cOLAtzwM81h631QOAx9q73vWuM/78q7/6q/WhD33oYT9/1rOetdJ2fvEXf7EeeOCBldYxmgceeKBe+tKX1v/8n/+zvvd7v7ee8IQn1E033VRf8zVfU3/4h39Yz3jGM7Z6iABsEvP19vVnf/Zn9RM/8RP1jGc8o77iK76i/tt/+29bPSRYnMaY887VV199xp9/7/d+rz70oQ897Of/2NraWu3du/est3PRRRe1xjey9773vfVf/+t/rfe85z318pe/vKqqXvGKV9SXfMmX1Fvf+tZ697vfvcUjBGCzmK+3r3/6T/9p3X333fX4xz++3vve99a3f/u3b/WQYHH+U2qG9DVf8zX15V/+5fWHf/iH9cIXvrD27t1bP/iDP1hVVbfddlu99KUvrSc/+cm1a9euuuKKK+pHfuRH6v777z9jHf/4/1n6q7/6q9qxY0f99E//dN188811xRVX1K5du+p5z3te/cEf/EFrnG9729tqx44d9clPfrKuvfbauvjii+vQoUN13XXX1dra2hm/e/LkyXrzm99cl112WR04cKBe9rKX1d/8zd884nr/9m//tq6//vq6/PLLa9euXfXsZz+7brnlltP19fX1euYzn1nPfOYza319/fTP77nnnnrSk55UV1555enjcd9999UnPvGJ+ru/+7vZ/Xnve99bl19+eX3rt37r6Z9ddtll9YpXvKJuu+22Onny5KM6PgCc38zXWzNfHzhwoB7/+Md3DgWcs3xjzLDuvvvueslLXlKvfOUr6+qrr67LL7+8qqpuvfXW2r9/f333d3937d+/vz784Q/XW97yljpy5Ej91E/91Ox63/3ud9fRo0frta99be3YsaN+8id/sr71W7+1PvWpT7X/1voVr3hFPf3pT68f//EfrzvuuKN+6Zd+qZ74xCfWT/zET5z+nRtuuKF+7dd+rV71qlfVlVdeWR/+8IfrpS996cPW9ZnPfKae//zn144dO+oNb3hDXXbZZfXbv/3b9epXv7qOHDlSb3rTm2rPnj31K7/yK/XVX/3V9UM/9EP1Mz/zM1VV9frXv74OHz5ct956a1144YVV9Q+T9rOe9ay65ppr6tZbb4378T/+x/+or/zKr6wLLjjz7+T+2T/7Z3XzzTfXn//5n9dXfMVXtI4RAOcn8/Xmz9cwpA04z73+9a/f+MeX+lVXXbVRVRu/8Au/8LDfX1tbe9jPXvva127s3bt348SJE6d/ds0112w89alPPf3nT3/60xtVtXHppZdu3HPPPad/ftttt21U1cZv/uZvxnF+5CMf2aiqjfe85z2nf/bWt751o6o2rr/++jN+91u+5Vs2Lr300tN/vvPOOzeqauN1r3vdGb/3qle9aqOqNt761ree/tmrX/3qjSc96Ukbd9111xm/+8pXvnLj0KFDZ+z/D/zAD2xccMEFGx/72Mc23vOe92xU1cY73/nOM5Z7cL+vueaauH8bGxsb+/bte9i+bGxsbHzgAx/YqKqN//Af/sPsOgA4P5mvt898/VAPru8jH/nIo1oOzjX+U2qGtWvXrrruuuse9vM9e/ac/vejR4/WXXfdVS94wQtqbW2tPvGJT8yu9zu+4zvqkksuOf3nF7zgBVVV9alPfao91u/6ru86488veMEL6u67764jR45UVdUHP/jBqqp64xvfeMbvvelNbzrjzxsbG/W+972vvvmbv7k2NjbqrrvuOv3Pi1/84jp8+PAZb4h+29veVs9+9rPrmmuuqde97nV11VVXPWwbT3va02pjY+Os/vZ5fX29du3a9bCf7969+3QdAB7KfL358zWMyH9KzbC+8Au/sHbu3Pmwn3/84x+vG2+8sT784Q+fnsgedPjw4dn1ftEXfdEZf35w0v3c5z7XHmta58GDB+uv//qv64ILLqgrrrjijN/70i/90jP+/NnPfrbuvffeuvnmm+vmm29+xG39/d///el/37lzZ91yyy31vOc9r3bv3l2//Mu/XDt27Gjvx549ex7x/yM+ceLE6ToAPJT5evPnaxiRxphhPVITdu+999ZVV11VBw8erLe//e11xRVX1O7du+uOO+6o7/u+7zuruIcH/1+ef2xjY6M91sdqnQ+O/+qrr65rrrnmEX/nOc95zhl//p3f+Z2q+ofm9S/+4i/q6U9/+qPa5kM96UlPesSXfjz4syc/+cntdQNwfjJfb/58DSPSGMNDfPSjH62777673v/+99cLX/jC0z//9Kc/vYWjmvfUpz61HnjggfrLv/zLM/7W+c/+7M/O+L0H34B5//3319d//dfPrveP/uiP6u1vf3tdd911deedd9YNN9xQf/zHf1yHDh1qjfO5z31u/af/9J/qgQceOOMFXP/9v//32rt3b33Jl3xJa70AjMV8fabHer6GEfl/jOEhHvyb3of+ze6pU6fqpptu2qohnZWXvOQlVVX1cz/3c2f8/J3vfOcZf77wwgvr277t2+p973tf/cmf/MnD1vPZz3729L/fd999de2119aTn/zk+tmf/dm69dZb6zOf+Uy9+c1vPmOZRxP/8PKXv7w+85nP1Pvf//7TP7vrrrvqPe95T33zN3/zI/7/xwDwj5mvl52vYUS+MYaHuPLKK+uSSy6pa665pt74xjfWjh076l3vetdK/1nVZnjuc59b3/md31k33XRTHT58uK688sq6/fbb65Of/OTDfvcd73hHfeQjH6mv+qqvqte85jX1ZV/2ZXXPPffUHXfcUb/7u79b99xzT1VV/eiP/mjdeeeddfvtt9eBAwfqOc95Tr3lLW+pG2+8sV7+8pfXN33TN1XVo4t/ePnLX17Pf/7z67rrrqv/9b/+Vz3hCU+om266qe6///764R/+4cf8uABwfjJfLztfP7jeqn/4f7mrqt71rnfVf/7P/7mqqm688cbH4nDAtqIxhoe49NJL67d+67fqe77ne+rGG2+sSy65pK6++ur6uq/7unrxi1+81cOLbrnllrrsssvq13/91+s3fuM36mu/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5+tHf/RHt3B0AGwm8/X2dcMNN9QNN9xwxs9e97rX1Rd/8RfXO9/5To0x5yX/KTVD+pqv+Zr68i//8vrDP/zDeuELX1h79+6tH/zBH6yqqttuu61e+tKX1pOf/OTatWtXXXHFFfUjP/Ijdf/995+xjn/8/yz91V/9Ve3YsaN++qd/um6++ea64oorateuXfW85z2v/uAP/qA1zre97W21Y8eO+uQnP1nXXnttXXzxxXXo0KG67rrram1t7YzfPXnyZL35zW+uyy67rA4cOFAve9nL6m/+5m8ecb1/+7d/W9dff31dfvnltWvXrnr2s59dt9xyy+n6+vp6PfOZz6xnPvOZtb6+fvrn99xzTz3pSU+qK6+88vTxuO++++oTn/jEWf3n0C984QvPaIof/NnjH//4+tM//dOzPi4AjMF8vTXz9SPZu3dvXXbZZXXvvfe2loftzjfGDOvuu++ul7zkJfXKV76yrr766rr88surqurWW2+t/fv313d/93fX/v3768Mf/nC95S1vqSNHjtRP/dRPza733e9+dx09erRe+9rX1o4dO+onf/In61u/9VvrU5/6VPtvrV/xilfU05/+9PrxH//xuuOOO+qXfumX6olPfGL9xE/8xOnfueGGG+rXfu3X6lWvelVdeeWV9eEPf7he+tKXPmxdn/nMZ+r5z39+7dixo97whjfUZZddVr/9279dr371q+vIkSP1pje9qfbs2VO/8iu/Ul/91V9dP/RDP1Q/8zM/U1VVr3/96+vw4cN166231oUXXlhV/zBpP+tZz6prrrmmbr311ke9b8eOHatjx47VE57whNaxAeD8Zr7euvn6yJEjderUqbrrrrvqV3/1V+tP/uRPTv/FBJx3NuA89/rXv37jH1/qV1111UZVbfzCL/zCw35/bW3tYT977Wtfu7F3796NEydOnP7ZNddcs/HUpz719J8//elPb1TVxqWXXrpxzz33nP75bbfdtlFVG7/5m78Zx/mRj3xko6o23vOe95z+2Vvf+taNqtq4/vrrz/jdb/mWb9m49NJLT//5zjvv3Kiqjde97nVn/N6rXvWqjaraeOtb33r6Z69+9as3nvSkJ23cddddZ/zuK1/5yo1Dhw6dsf8/8AM/sHHBBRdsfOxjH9t4z3ves1FVG+985zvPWO7B/b7mmmvi/k35kR/5kY2q2rj99ttbywNwfjBfb7/5+sUvfvFGVW1U1cbOnTs3Xvva126sr6+f9fJwLvGfUjOsXbt21XXXXfewn+/Zs+f0vx89erTuuuuuesELXlBra2v1iU98Yna93/Ed31GXXHLJ6T+/4AUvqKqqT33qU+2xftd3fdcZf37BC15Qd999dx05cqSqqj74wQ9WVdUb3/jGM37vTW960xl/3tjYqPe97331zd/8zbWxsVF33XXX6X9e/OIX1+HDh+uOO+44/ftve9vb6tnPfnZdc8019brXva6uuuqqh23jaU97Wm1sbLS+Lf7Yxz5WP/zDP1yveMUr6mu/9msf9fIAnP/M11s3X7/jHe+o//gf/2P923/7b+v5z39+nTp1qj7/+c+f9fJwLvGfUjOsL/zCL6ydO3c+7Ocf//jH68Ybb6wPf/jDpyeyBx0+fHh2vV/0RV90xp8fnHQ/97nPtcea1nnw4MH667/+67rgggvqiiuuOOP3vvRLv/SMP3/2s5+te++9t26++ea6+eabH3Fbf//3f3/633fu3Fm33HJLPe95z6vdu3fXL//yL9eOHTva+/FQn/jEJ+pbvuVb6su//Mvrl37plx6TdQJw/jFfb918/dznPvf0v1999dX1lV/5lXXttdee11FVjEtjzLAe+jfND7r33nvrqquuqoMHD9bb3/72uuKKK2r37t11xx131Pd93/edVdzDg/8vzz+2sbHRHutjtc4Hx3/11VfXNddc84i/85znPOeMP//O7/xOVVWdOHGi/uIv/qKe/vSnP6ptPpL/83/+T33DN3xDHTp0qD74wQ/WgQMHVl4nAOcn8/XWzdcPtXPnznrZy15W73jHO2p9ff0RzwucyzTG8BAf/ehH6+677673v//99cIXvvD0zz/96U9v4ajmPfWpT60HHnig/vIv//KMv3X+sz/7szN+78E3YN5///319V//9bPr/aM/+qN6+9vfXtddd13deeeddcMNN9Qf//Ef16FDh9pjvfvuu+sbvuEb6uTJk3X77bfXk570pPa6ABiT+fpMS8zXj2R9fb02Njbq6NGjGmPOO/4fY3iIB/+m96F/s3vq1Km66aabtmpIZ+UlL3lJVVX93M/93Bk/f+c733nGny+88ML6tm/7tnrf+95Xf/Inf/Kw9Xz2s589/e/33XdfXXvttfXkJz+5fvZnf7ZuvfXW+sxnPlNvfvObz1jm0cQ/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" + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "execution_count": 13 + }, + { + "cell_type": "markdown", + "id": "801bba9f50c235fa", + "metadata": {}, + "source": [ + "## Downloading Further Heliostat Data\n", + "\n", + "We have spent most of this tutorial looking at the calibration data, since this holds the most potential for machine learning applications. However, it is worth considering the other available data as well. We will download deflectometry data and properties data for the heliostat \"AA23\" in the following and briefly inspect it:" + ] + }, + { + "cell_type": "code", + "id": "8687d5e3ddb27454", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:13:42.504761Z", + "start_time": "2026-01-30T11:13:39.906286Z" + } + }, + "source": [ + "client.get_heliostat_data(\n", + " heliostats=[\"AA23\"],\n", + " collections=[mappings.SAVE_DEFLECTOMETRY.lower(), mappings.SAVE_PROPERTIES.lower()],\n", + ")" + ], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Processing Items in Heliostat AA23-heliostat-catalog: 100%|██████████| 1/1 [00:01<00:00, 1.85s/Item]\n", + "Processing Items in Heliostat AA23-heliostat-catalog: 100%|██████████| 1/1 [00:00<00:00, 12.34Item/s]\n" + ] + } + ], + "execution_count": 14 + }, + { + "cell_type": "markdown", + "id": "60530f3677d0c415", + "metadata": {}, + "source": [ + "### Properties Data\n", + "\n", + "There should now be a new folder in your download path called ``AA23``. Within this folder there will be two more subfolders, ``Deflectometry`` and ``Properties``.\n", + "\n", + "Let's first look at the properties data:" + ] + }, + { + "cell_type": "code", + "id": "a038b7fc9aa256d6", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:13:42.515152Z", + "start_time": "2026-01-30T11:13:42.511660Z" + } + }, + "source": [ + "import json\n", + "\n", + "from IPython.display import JSON\n", + "\n", + "# Load the file.\n", + "with open(\n", + " Path(download_path) / \"AA23\" / \"Properties\" / \"AA23-heliostat-properties.json\", \"r\"\n", + ") as f:\n", + " properties_data = json.load(f)\n", + "\n", + "# Display the file (nice formatting).\n", + "JSON(properties_data)" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "" + ], + "application/json": { + "heliostat_position": [ + 50.9136467956509, + 6.387012480022248, + 88.59005737 + ], + "height": 2.559999942779541, + "width": 3.2200000286102295, + "initial_orientation": [ + 0.0, + -1.0, + 0.0 + ], + "kinematic_properties": { + "actuators": [ + { + "type_axis": "linear", + "min_increment": 0, + "max_increment": 69296, + "increment": 154166.6667, + "offset_shift": 0, + "initial_stroke_length": 0.075016089, + "offset": 0.335308, + "pivot_radius": 0.338095, + "radius_shift": 0, + "clockwise_axis_movement": 0, + "initial_angle": 0.005839586, + "min_movement_angle": 0.004434882, + "max_movement_angle": 1.570796327, + "movement_speed": 0 + }, + { + "type_axis": "linear", + "min_increment": 0, + "max_increment": 75451, + "increment": 154166.6667, + "offset_shift": 0, + "initial_stroke_length": 0.078892626, + "offset": 0.340771, + "pivot_radius": 0.3191, + "radius_shift": 0, + "clockwise_axis_movement": 1, + "initial_angle": 0.939715322, + "min_movement_angle": -0.95993, + "max_movement_angle": 0.929079209, + "movement_speed": 0 + } + ], + "joint_translation_e_1": 0.0, + "joint_translation_n_1": 0.0, + "joint_translation_u_1": 0.0, + "joint_translation_e_2": 0.0, + "joint_translation_n_2": 0.0, + "joint_translation_u_2": 0.0, + "concentrator_translation_e": 0.0, + "concentrator_translation_n": 0.175, + "concentrator_translation_u": 0.0 + }, + "facet_properties": { + "canting_type": "receiver canting", + "number_of_facets": 4, + "facets": [ + { + "translation_vector": [ + -0.8075, + 0.6425, + 0.0402 + ], + "canting_e": [ + 0.8024901549337139, + -0.0, + -0.003971726517017195 + ], + "canting_n": [ + 1.244240616731202E-5, + 0.6374950534642229, + 0.0025103732841759313 + ] + }, + { + "translation_vector": [ + 0.8075, + 0.6425, + 0.0402 + ], + "canting_e": [ + 0.8024901549337139, + -0.0, + 0.003971726517017195 + ], + "canting_n": [ + -1.244240616731202E-5, + 0.6374950534642229, + 0.0025103732841759313 + ] + }, + { + "translation_vector": [ + -0.8075, + -0.6425, + 0.0402 + ], + "canting_e": [ + 0.8024901549337139, + -0.0, + -0.003971726517017195 + ], + "canting_n": [ + -1.244240616731202E-5, + 0.6374950534642229, + -0.0025103732841759313 + ] + }, + { + "translation_vector": [ + 0.8075, + -0.6425, + 0.0402 + ], + "canting_e": [ + 0.8024901549337139, + -0.0, + 0.003971726517017195 + ], + "canting_n": [ + 1.244240616731202E-5, + 0.6374950534642229, + -0.0025103732841759313 + ] + } + ] + }, + "renovation": "2021-04-15" + } + }, + "execution_count": 15, + "metadata": { + "application/json": { + "expanded": false, + "root": "root" + } + }, + "output_type": "execute_result" + } + ], + "execution_count": 15 + }, + { + "cell_type": "markdown", + "id": "403852905b2a9013", + "metadata": {}, + "source": [ + "The JSON output contains detailed information on the heliostat, including:\n", + "- **Position:** Its coordinates in the field\n", + "- **Dimensions:** Its height and width\n", + "- **Orientation:** Its standard initial orientation (East, North, Up coordinates)\n", + "- **Kinematics:** Properties of the actuators and joint offsets\n", + "- **Facets:** The number of facets, canting type, and translation vectors from the center\n", + "- **Renovation:** The date the heliostat was last renovated\n", + "\n", + "Detailed diagrams explaining these parameters are available on the [``PAINT`` website](https://paint-database.org/data)." + ] + }, + { + "cell_type": "markdown", + "id": "be71d0542674d482", + "metadata": {}, + "source": [ + "### Deflectometry Data\n", + "\n", + "Deflectometry data is stored in HDF5 files and contains detailed surface measurements for each heliostat facet. You will find two files in the ``Deflectometry`` folder:\n", + "- A raw deflectometry HDF5 file\n", + "- A \"filled\" HDF5 file, where missing values were substituted with ideal vectors. **Note:** This filling was performed by the measurement company using proprietary software; it is not part of the ``PAINT`` pre-processing.\n", + "\n", + "We can inspect the HDF5 structure using the helper function below:" + ] + }, + { + "cell_type": "code", + "id": "89f178d1a7eb0db3", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:13:42.981624Z", + "start_time": "2026-01-30T11:13:42.616493Z" + } + }, + "source": [ + "import h5py\n", + "\n", + "\n", + "# Helper function to print the structure of the HDF5 file.\n", + "def print_hdf5_structure(name: str, obj: h5py.Group | h5py.Dataset) -> None:\n", + " \"\"\"\n", + " Print clear summary of HDF5 file structures.\n", + "\n", + " Parameters\n", + " ----------\n", + " name : str\n", + " Name of the HDF5 element.\n", + " obj : h5py.Dataset | h5py.Group\n", + " Object to be inspected.\n", + " \"\"\"\n", + " indent = name.count(\"/\") * \" \"\n", + " if isinstance(obj, h5py.Group):\n", + " print(f\"{indent}📂 {name.split('/')[-1]}/\")\n", + " elif isinstance(obj, h5py.Dataset):\n", + " print(\n", + " f\"{indent}📄 {name.split('/')[-1]} (shape: {obj.shape}, type: {obj.dtype})\"\n", + " )\n", + "\n", + "\n", + "filename = (\n", + " Path(download_path)\n", + " / \"AA23\"\n", + " / \"Deflectometry\"\n", + " / \"AA23-filled-2021-10-13Z09-27-07Z-deflectometry.h5\"\n", + ")\n", + "\n", + "with h5py.File(filename, \"r\") as f:\n", + " print(f\"Structure of {filename}:\")\n", + " f.visititems(print_hdf5_structure)" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Structure of PAINT_tutorial_data/AA23/Deflectometry/AA23-filled-2021-10-13Z09-27-07Z-deflectometry.h5:\n", + "📂 facet1/\n", + " 📄 surface_normals (shape: (80760, 3), type: float32)\n", + " 📄 surface_points (shape: (80760, 3), type: float32)\n", + "📂 facet2/\n", + " 📄 surface_normals (shape: (80760, 3), type: float32)\n", + " 📄 surface_points (shape: (80760, 3), type: float32)\n", + "📂 facet3/\n", + " 📄 surface_normals (shape: (80760, 3), type: float32)\n", + " 📄 surface_points (shape: (80760, 3), type: float32)\n", + "📂 facet4/\n", + " 📄 surface_normals (shape: (80760, 3), type: float32)\n", + " 📄 surface_points (shape: (80760, 3), type: float32)\n" + ] + } + ], + "execution_count": 16 + }, + { + "cell_type": "markdown", + "id": "ba0f532b6760688b", + "metadata": {}, + "source": [ + "This file contains detailed measurements (80,760 points) for each of the four facets. These can be used to recreate heliostat surfaces; however, that is beyond the scope of this tutorial." + ] + }, + { + "cell_type": "markdown", + "id": "e2320d302297779c", + "metadata": {}, + "source": [ + "## Further Data\n", + "\n", + "To conclude, let's look at two additional data types available via ``PAINT``.\n", + "\n", + "### Weather Data\n", + "\n", + "Weather data is available from a station located directly next to the tower in Jülich, as well as from the nearest DWD (German Weather Service) station. The code below downloads one month of Jülich data and the complete DWD dataset:" + ] + }, + { + "cell_type": "code", + "id": "a4f7bd6ef3e58743", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:14:08.487920Z", + "start_time": "2026-01-30T11:13:42.994005Z" + } + }, + "source": [ + "from datetime import datetime\n", + "\n", + "client.get_weather_data(\n", + " data_sources=[\"Jülich\"],\n", + " start_date=datetime.strptime(\"2023-01-01Z00:00:00Z\", mappings.TIME_FORMAT),\n", + " end_date=datetime.strptime(\"2023-02-01Z00:00:00Z\", mappings.TIME_FORMAT),\n", + ")\n", + "client.get_weather_data(data_sources=[\"DWD\"])" + ], + "outputs": [], + "execution_count": 17 + }, + { + "cell_type": "markdown", + "id": "8e648142d124475", + "metadata": {}, + "source": [ + "This weather data is also in HDF5 format, but each source has a slightly different structure. We can first consider the DWD data:" + ] + }, + { + "cell_type": "code", + "id": "fe1643257ddd3be5", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:14:08.524070Z", + "start_time": "2026-01-30T11:14:08.515926Z" + } + }, + "source": [ + "dwd_weather = Path(download_path) / \"Weather\" / \"dwd-weather.h5\"\n", + "\n", + "with h5py.File(dwd_weather, \"r\") as f:\n", + " print(\"Structure of DWD Weather Station (ID 15000):\")\n", + " f.visititems(print_hdf5_structure)" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Structure of DWD Weather Station (ID 15000):\n", + "📂 15000/\n", + " 📂 cloud_cover_1h/\n", + " 📄 time (shape: (25561,), type: object)\n", + " 📄 value (shape: (25561,), type: float64)\n", + " 📂 global_radiation_10min/\n", + " 📄 time (shape: (153361,), type: object)\n", + " 📄 value (shape: (153361,), type: float64)\n", + " 📂 humidity_1h/\n", + " 📄 time (shape: (25561,), type: object)\n", + " 📄 value (shape: (25561,), type: float64)\n", + " 📂 long_wave_radiation_10min/\n", + " 📄 time (shape: (153361,), type: object)\n", + " 📄 value (shape: (153361,), type: float64)\n", + " 📂 pressure_vapor_1h/\n", + " 📄 time (shape: (25561,), type: object)\n", + " 📄 value (shape: (25561,), type: float64)\n", + " 📂 short_wave_radiation_10min/\n", + " 📄 time (shape: (153361,), type: object)\n", + " 📄 value (shape: (153361,), type: float64)\n", + " 📂 sunshine_duration_10min/\n", + " 📄 time (shape: (153361,), type: object)\n", + " 📄 value (shape: (153361,), type: float64)\n", + " 📂 visibility_range_1h/\n", + " 📄 time (shape: (25561,), type: object)\n", + " 📄 value (shape: (25561,), type: float64)\n", + " 📂 weather_type_1h/\n", + " 📄 time (shape: (25561,), type: object)\n", + " 📄 value (shape: (25561,), type: float64)\n" + ] + } + ], + "execution_count": 18 + }, + { + "cell_type": "markdown", + "id": "3f2f48a15f309ec9", + "metadata": {}, + "source": [ + "Here the data is grouped by variable and we can clearly see that there are some variables available at 10min resolution and others at 1h resolution. For each variable we have:\n", + "- A time dataset containing the time stamps for each measurement\n", + "- The value dataset containing the recorded values\n", + "\n", + "The Jülich weather data on the other hand is all at the same temporal resolution - a very high 1s resolution which results in the following structure:" + ] + }, + { + "cell_type": "code", + "id": "d29fd15549c7ad30", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:14:08.549541Z", + "start_time": "2026-01-30T11:14:08.543611Z" + } + }, + "source": [ + "juelich_weather = Path(download_path) / \"Weather\" / \"2023-01-juelich-weather.h5\"\n", + "\n", + "with h5py.File(juelich_weather, \"r\") as f:\n", + " print(\"Structure of the Jülich weather data:\")\n", + " f.visititems(print_hdf5_structure)" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Structure of the Jülich weather data:\n", + "📄 atmospheric_pressure (shape: (2678303,), type: float64)\n", + "📄 diffuse_irradiation (shape: (2678303,), type: float64)\n", + "📄 direct_irradiation (shape: (2678303,), type: float64)\n", + "📄 global_irradiation (shape: (2678303,), type: float64)\n", + "📄 precipitation (shape: (2678303,), type: float64)\n", + "📄 relative_humidity (shape: (2678303,), type: float64)\n", + "📄 temperature (shape: (2678303,), type: float64)\n", + "📄 temperature_diffuse (shape: (2678303,), type: float64)\n", + "📄 temperature_direct (shape: (2678303,), type: float64)\n", + "📄 temperature_global (shape: (2678303,), type: float64)\n", + "📄 time (shape: (2678303,), type: object)\n", + "📄 wind_direction (shape: (2678303,), type: float64)\n", + "📄 wind_speed (shape: (2678303,), type: float64)\n" + ] + } + ], + "execution_count": 19 + }, + { + "cell_type": "markdown", + "id": "eb1d987b20864b02", + "metadata": {}, + "source": [ + "The Jülich data utilizes a flatter structure. All weather variable datasets contain the values, while a single ``time`` dataset contains the associated timestamps." + ] + }, + { + "cell_type": "markdown", + "id": "a737c94d4eb7606b", + "metadata": {}, + "source": [ + "### Tower Measurements Data\n", + "\n", + "Finally, we will download a small file containing properties of the solar tower itself." + ] + }, + { + "cell_type": "code", + "id": "b5c80db545b8cc51", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:14:08.762489Z", + "start_time": "2026-01-30T11:14:08.576527Z" + } + }, + "source": [ + "client.get_tower_measurements()" + ], + "outputs": [], + "execution_count": 20 + }, + { + "cell_type": "markdown", + "id": "4a4e245c031848b9", + "metadata": {}, + "source": [ + "After running this code you should see a new JSON file ``WRI1030197-tower-measurements.json`` in your download folder. We can inspect it with the same code as before:" + ] + }, + { + "cell_type": "code", + "id": "c97107916b2c01e0", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-30T11:14:08.784215Z", + "start_time": "2026-01-30T11:14:08.780482Z" + } + }, + "source": [ + "# Load the file.\n", + "with open(Path(download_path) / \"WRI1030197-tower-measurements.json\", \"r\") as f:\n", + " tower_data = json.load(f)\n", + "\n", + "# Display the file (nice formatting).\n", + "JSON(tower_data)" + ], + "outputs": [ + { + "data": { + "text/plain": [ + "" + ], + "application/json": { + "power_plant_properties": { + "ID": "WRI1030197", + "coordinates": [ + 50.913421122592574, + 6.387824755874856, + 87.0 + ] + }, + "solar_tower_juelich_upper": { + "type": "planar", + "normal_vector": [ + 0, + 1, + 0 + ], + "coordinates": { + "center": [ + 50.91339203683997, + 6.387824563513243, + 130.09766666666667 + ], + "upper_left": [ + 50.91339196507306, + 6.387885982262168, + 133.684 + ], + "upper_middle": [ + 50.91339190867827, + 6.387824583774971, + 133.71 + ], + "upper_right": [ + 50.91339211259599, + 6.387763286988281, + 133.719 + ], + "lower_left": [ + 50.913391865959426, + 6.387886052532387, + 126.476 + ], + "lower_right": [ + 50.91339215692524, + 6.387763472205384, + 126.506 + ] + } + }, + "solar_tower_juelich_lower": { + "type": "planar", + "normal_vector": [ + 0, + 1, + 0 + ], + "coordinates": { + "center": [ + 50.91339203683997, + 6.387824563513243, + 122.8815 + ], + "upper_left": [ + 50.913391865959426, + 6.387886052532387, + 126.476 + ], + "upper_right": [ + 50.91339215692524, + 6.387763472205384, + 126.506 + ], + "lower_left": [ + 50.913391839040266, + 6.387886038089168, + 119.268 + ], + "lower_middle": [ + 50.913392106574314, + 6.387824542765121, + 119.269 + ], + "lower_right": [ + 50.9133923375531, + 6.387763217765236, + 119.279 + ] + } + }, + "multi_focus_tower": { + "type": "planar", + "normal_vector": [ + 0, + 1, + 0 + ], + "coordinates": { + "center": [ + 50.91339645088695, + 6.387574436728054, + 138.97975 + ], + "upper_left": [ + 50.91339628900999, + 6.387612983329586, + 142.175 + ], + "upper_right": [ + 50.91339661677292, + 6.387536032350528, + 142.172 + ], + "lower_left": [ + 50.913396343415734, + 6.387612841591359, + 135.789 + ], + "lower_right": [ + 50.91339655432385, + 6.3875358896401675, + 135.783 + ] + } + }, + "receiver": { + "type": "convex_cylinder", + "normal_vector": [ + 0.0, + 0.90630779, + -0.42261826 + ], + "coordinates": { + "center": [ + 50.91341660151, + 6.387825304776098, + 142.22674999999998 + ], + "receiver_outer_upper_left": [ + 50.91342727218299, + 6.387856856914401, + 144.805 + ], + "receiver_outer_upper_right": [ + 50.91342773925188, + 6.387792121250146, + 144.82 + ], + "receiver_outer_lower_left": [ + 50.913405475562435, + 6.387856291534852, + 139.596 + ], + "receiver_outer_lower_right": [ + 50.91340570660374, + 6.3877922506716125, + 139.592 + ], + "receiver_inner_lower_left": [ + 50.913406544144294, + 6.387853925842859, + 139.86 + ], + "receiver_inner_lower_right": [ + 50.91340664929648, + 6.387795301404112, + 139.862 + ], + "receiver_inner_upper_left": [ + 50.91342645401072, + 6.387854205350705, + 144.592 + ], + "receiver_inner_upper_right": [ + 50.913426766473705, + 6.3877954119834275, + 144.593 + ] + } + } + } + }, + "execution_count": 21, + "metadata": { + "application/json": { + "expanded": false, + "root": "root" + } + }, + "output_type": "execute_result" + } + ], + "execution_count": 21 + }, + { + "cell_type": "markdown", + "id": "8b2e4e8791029079", + "metadata": {}, + "source": [ + "This file contains crucial properties of the solar tower, including:\n", + "- **ID:** The tower identifier\n", + "- **Coordinates:** Latitude, longitude, and elevation\n", + "- **Targets:** Coordinates for the various calibration targets (corners and center) and the receiver" + ] + }, + { + "cell_type": "markdown", + "id": "7c8663ca1667f0bd", + "metadata": {}, + "source": [ + "## Conclusion\n", + "\n", + "This concludes the tutorial. Please check our [documentation](https://paint.readthedocs.io/en/latest/usage.html) for further scripts and information. We hope you enjoy using the ``PAINT`` database!" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}