From b6729aae2040c5efbf1c6de92ca10181f113b26d Mon Sep 17 00:00:00 2001 From: roberto_esaclear Date: Thu, 11 Jun 2026 19:14:12 +0200 Subject: [PATCH] Support Sentinel-2 level aliases Bump pyproject version to 0.1.29. --- docs/source/sentinel2_reference.rst | 117 +++++---- notebooks/1_search_n_download.ipynb | 330 +++++++++++++++++++++++++- phidown/config.json | 4 +- phidown/search.py | 46 +++- pyproject.toml | 2 +- tests/test_sentinel2_level_aliases.py | 75 ++++++ 6 files changed, 509 insertions(+), 65 deletions(-) create mode 100644 tests/test_sentinel2_level_aliases.py diff --git a/docs/source/sentinel2_reference.rst b/docs/source/sentinel2_reference.rst index 243c5ec..d036262 100644 --- a/docs/source/sentinel2_reference.rst +++ b/docs/source/sentinel2_reference.rst @@ -22,7 +22,7 @@ Search Parameters When searching for Sentinel-2 data, parameters are passed in two ways: -1. **Direct Parameters:** Passed directly to the ``search()`` method +1. **Direct Parameters:** Passed directly to the ``query_by_filter()`` method - ``collection_name`` - Mission/collection identifier - ``product_type`` - Product type (S2MSI1C, S2MSI2A, etc.) - ``orbit_direction`` - Orbit direction (ASCENDING/DESCENDING) @@ -55,13 +55,17 @@ When searching for Sentinel-2 data, parameters are passed in two ways: .. note:: **Processing Level Filtering** - ``processingLevel`` as an attribute does not filter correctly. Instead, use ``product_type`` - parameter to specify: + Use ``product_type`` for processing-level selection. phidown accepts common aliases + such as ``'L1C'``/``'Level-1C'`` and ``'L2A'``/``'Level-2A'``, then sends the + canonical CDSE identifiers: - ``'S2MSI1C'`` for Level-1C (Top-of-Atmosphere reflectance) - ``'S2MSI2A'`` for Level-2A (Bottom-of-Atmosphere reflectance) - ``'S2MSI2B'`` for Level-2B (archived format) + If ``processingLevel`` or ``productType`` is supplied in ``attributes``, + the same Sentinel-2 aliases are normalized, but ``product_type`` is clearer. + 3. **Method Parameters** These parameters are passed directly to ``query_by_filter()``, not in the attributes dictionary: @@ -79,7 +83,8 @@ Use ``'SENTINEL-2'`` as the collection name. .. code-block:: python - results = searcher.search(collection_name='SENTINEL-2') + searcher.query_by_filter(collection_name='SENTINEL-2') + results = searcher.execute_query() Geographic Parameters ^^^^^^^^^^^^^^^^^^^^^ @@ -92,10 +97,11 @@ Region of Interest defined in Well Known Text (WKT) format with coordinates in d # Polygon example aoi_wkt = 'POLYGON((12.4 41.9, 12.5 41.9, 12.5 42.0, 12.4 42.0, 12.4 41.9))' - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', aoi_wkt=aoi_wkt ) + results = searcher.execute_query() Tile Identifier """"""""""""""" @@ -104,10 +110,11 @@ Sentinel-2 data is organized in tiles following the Military Grid Reference Syst .. code-block:: python # Search for specific tile - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', attributes={'tileId': '32TQM'} ) + results = searcher.execute_query() Product Parameters ^^^^^^^^^^^^^^^^^^ @@ -139,25 +146,28 @@ Sentinel-2 offers various product types: .. code-block:: python # Search for Level-1C products - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', product_type='S2MSI1C' ) + results = searcher.execute_query() Processing Level """""""""""""""" -Available processing levels: +Available processing-level product types: * ``S2MSI1C`` - Level-1C (Top-of-Atmosphere reflectance) * ``S2MSI2A`` - Level-2A (Bottom-of-Atmosphere reflectance) +* ``S2MSI2B`` - Level-2B (archived format) .. code-block:: python # Search for Level-2A products - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', - attributes={'processingLevel': 'S2MSI2A'} + product_type='S2MSI2A' ) + results = searcher.execute_query() Platform Serial Identifier """""""""""""""""""""""""" @@ -169,10 +179,11 @@ Sentinel-2 constellation satellites: .. code-block:: python # Search for Sentinel-2A data only - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', attributes={'platformSerialIdentifier': 'A'} ) + results = searcher.execute_query() Instrument Short Name """"""""""""""""""""" @@ -181,10 +192,11 @@ Instrument Short Name .. code-block:: python # Search for MSI instrument data - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', attributes={'instrumentShortName': 'MSI'} ) + results = searcher.execute_query() Sensor Mode """"""""""" @@ -197,10 +209,11 @@ Sentinel-2 sensor modes: .. code-block:: python # Search for normal observation mode - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', - attributes={'sensorMode': 'INS-NOBS'} + attributes={'operationalMode': 'INS-NOBS'} ) + results = searcher.execute_query() Cloud Cover ^^^^^^^^^^^ @@ -236,10 +249,11 @@ Orbit Direction .. code-block:: python - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', orbit_direction='DESCENDING' ) + results = searcher.execute_query() Orbit Number """""""""""" @@ -248,16 +262,18 @@ Absolute orbit number (integer value or range). .. code-block:: python # Single orbit - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', attributes={'orbitNumber': '12345'} ) + results = searcher.execute_query() # Orbit range - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', attributes={'orbitNumber': '[12345,12350]'} ) + results = searcher.execute_query() Relative Orbit Number """"""""""""""""""""" @@ -266,10 +282,11 @@ Relative orbit number (1-143 for Sentinel-2), representing the orbit within a re .. code-block:: python # Search for relative orbit 51 - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', attributes={'relativeOrbitNumber': '51'} ) + results = searcher.execute_query() Quality and Processing ^^^^^^^^^^^^^^^^^^^^^^ @@ -281,16 +298,18 @@ Processing baseline version (affects product quality and algorithms used). .. code-block:: python # Search for specific processing baseline - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', attributes={'processingBaseline': '04.00'} ) + results = searcher.execute_query() # Search for baseline range - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', attributes={'processingBaseline': '[04.00,05.00]'} ) + results = searcher.execute_query() Status """""" @@ -303,10 +322,11 @@ Product availability status: .. code-block:: python # Search for immediately available products - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', attributes={'status': 'ONLINE'} ) + results = searcher.execute_query() Mission Take ID """"""""""""""" @@ -315,10 +335,11 @@ Mission take identifier for specific acquisition sessions. .. code-block:: python # Search for specific mission take - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', attributes={'missionTakeId': 'GS2A_20230601T101030_000123_N04.00'} ) + results = searcher.execute_query() Practical Examples ------------------ @@ -330,8 +351,6 @@ Example 1: Basic Level-1C Search from phidown import CopernicusDataSearcher - searcher = CopernicusDataSearcher() - # Search for Level-1C products with low cloud cover searcher = CopernicusDataSearcher() searcher.query_by_filter( @@ -357,17 +376,16 @@ Example 2: Level-2A Surface Reflectance searcher = CopernicusDataSearcher() # Search for atmospherically corrected Level-2A products - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', product_type='S2MSI2A', aoi_wkt='POLYGON((12.4 41.9, 12.5 41.9, 12.5 42.0, 12.4 42.0, 12.4 41.9))', start_date='2023-06-01', end_date='2023-06-30', - attributes={ - 'cloudCover': '[0,10]', - 'processingLevel': 'S2MSI2A' - } + cloud_cover_threshold=10, + top=10 ) + results = searcher.execute_query() print(f"Found {len(results)} Level-2A products") @@ -381,16 +399,15 @@ Example 3: Specific Tile Search searcher = CopernicusDataSearcher() # Search for specific tile over time - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', product_type='S2MSI1C', start_date='2023-01-01', end_date='2023-12-31', - attributes={ - 'tileId': '32TQM', - 'cloudCover': '[0,30]' - } + cloud_cover_threshold=30, + attributes={'tileId': '32TQM'} ) + results = searcher.execute_query() print(f"Found {len(results)} products for tile 32TQM") @@ -405,17 +422,18 @@ Example 4: Time Series Analysis searcher = CopernicusDataSearcher() # Search for consistent time series data - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', product_type='S2MSI1C', start_date='2023-01-01', end_date='2023-12-31', + cloud_cover_threshold=20, attributes={ 'tileId': '32TQM', - 'cloudCover': '[0,20]', 'relativeOrbitNumber': '51' } ) + results = searcher.execute_query() # Group by date to analyze temporal coverage results['Date'] = pd.to_datetime(results['ContentDate']).dt.date @@ -433,29 +451,27 @@ Example 5: Multi-Platform Comparison searcher = CopernicusDataSearcher() # Compare data from both Sentinel-2A and Sentinel-2B - s2a_results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', product_type='S2MSI1C', aoi_wkt='POLYGON((12.4 41.9, 12.5 41.9, 12.5 42.0, 12.4 42.0, 12.4 41.9))', start_date='2023-06-01', end_date='2023-06-30', - attributes={ - 'platform': 'S2A', - 'cloudCover': '[0,15]' - } + cloud_cover_threshold=15, + attributes={'platformSerialIdentifier': 'A'} ) + s2a_results = searcher.execute_query() - s2b_results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', product_type='S2MSI1C', aoi_wkt='POLYGON((12.4 41.9, 12.5 41.9, 12.5 42.0, 12.4 42.0, 12.4 41.9))', start_date='2023-06-01', end_date='2023-06-30', - attributes={ - 'platform': 'S2B', - 'cloudCover': '[0,15]' - } + cloud_cover_threshold=15, + attributes={'platformSerialIdentifier': 'B'} ) + s2b_results = searcher.execute_query() print(f"Sentinel-2A: {len(s2a_results)} products") print(f"Sentinel-2B: {len(s2b_results)} products") @@ -470,17 +486,16 @@ Example 6: Processing Baseline Filtering searcher = CopernicusDataSearcher() # Search for products with latest processing baseline - results = searcher.search( + searcher.query_by_filter( collection_name='SENTINEL-2', product_type='S2MSI2A', aoi_wkt='POLYGON((12.4 41.9, 12.5 41.9, 12.5 42.0, 12.4 42.0, 12.4 41.9))', start_date='2023-06-01', end_date='2023-06-30', - attributes={ - 'processingBaseline': '[04.00,05.00]', - 'cloudCover': '[0,25]' - } + cloud_cover_threshold=25, + attributes={'processingBaseline': '[04.00,05.00]'} ) + results = searcher.execute_query() print(f"Found {len(results)} products with processing baseline 4.00-5.00") diff --git a/notebooks/1_search_n_download.ipynb b/notebooks/1_search_n_download.ipynb index a61731e..1a1e424 100644 --- a/notebooks/1_search_n_download.ipynb +++ b/notebooks/1_search_n_download.ipynb @@ -22,18 +22,143 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "10d56e22", "metadata": {}, "outputs": [ { - "ename": "", - "evalue": "", - "output_type": "error", - "traceback": [ - "\u001b[1;31mFailed to start the Kernel. \n", - "\u001b[1;31mView Jupyter log for further details." + "name": "stdout", + "output_type": "stream", + "text": [ + "Warning: No AOI (aoi_wkt) provided. Coverage calculation requires an AOI to measure against.\n", + "Number of results: 5337\n" ] + }, + { + "data": { + "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", + "
IdcoverageNameS3PathGeoFootprintOriginDateAttributes
000cd06ea-80e6-4ee2-9fb3-b5c0ec189186NoneS1A_S6_RAW__0SDV_20240502T195132_20240502T1951.../eodata/Sentinel-1/SAR/S6_RAW__0S/2024/05/02/S...{'type': 'Polygon', 'coordinates': [[[-24.5551...2024-05-02 20:15:12[{'@odata.type': '#OData.CSC.StringAttribute',...
1e68c7e10-7213-443e-857c-f9ddadb648fbNoneS1A_S4_RAW__0SDV_20240502T193657_20240502T1937.../eodata/Sentinel-1/SAR/S4_RAW__0S/2024/05/02/S...{'type': 'Polygon', 'coordinates': [[[-12.4077...2024-05-02 20:15:29[{'@odata.type': '#OData.CSC.StringAttribute',...
28f52959c-83d4-4568-8b9c-3bdc7933c14bNoneS1A_S1_RAW__0SDH_20240502T121147_20240502T1212.../eodata/Sentinel-1/SAR/S1_RAW__0S/2024/05/02/S...{'type': 'Polygon', 'coordinates': [[[-88.2859...2024-05-02 13:07:28[{'@odata.type': '#OData.CSC.StringAttribute',...
354192b79-3683-4234-82f6-78f154af2d1cNoneS1A_S4_RAW__0SDV_20240502T062925_20240502T0629.../eodata/Sentinel-1/SAR/S4_RAW__0S/2024/05/02/S...{'type': 'Polygon', 'coordinates': [[[-177.858...2024-05-02 07:16:16[{'@odata.type': '#OData.CSC.StringAttribute',...
4c03e2735-12b5-47cd-9deb-af959af4aa55NoneS1A_S6_RAW__0SDV_20240502T055859_20240502T0559.../eodata/Sentinel-1/SAR/S6_RAW__0S/2024/05/02/S...{'type': 'Polygon', 'coordinates': [[[-12.6051...2024-05-02 07:16:24[{'@odata.type': '#OData.CSC.StringAttribute',...
\n", + "
" + ], + "text/plain": [ + " Id coverage \\\n", + "0 00cd06ea-80e6-4ee2-9fb3-b5c0ec189186 None \n", + "1 e68c7e10-7213-443e-857c-f9ddadb648fb None \n", + "2 8f52959c-83d4-4568-8b9c-3bdc7933c14b None \n", + "3 54192b79-3683-4234-82f6-78f154af2d1c None \n", + "4 c03e2735-12b5-47cd-9deb-af959af4aa55 None \n", + "\n", + " Name \\\n", + "0 S1A_S6_RAW__0SDV_20240502T195132_20240502T1951... \n", + "1 S1A_S4_RAW__0SDV_20240502T193657_20240502T1937... \n", + "2 S1A_S1_RAW__0SDH_20240502T121147_20240502T1212... \n", + "3 S1A_S4_RAW__0SDV_20240502T062925_20240502T0629... \n", + "4 S1A_S6_RAW__0SDV_20240502T055859_20240502T0559... \n", + "\n", + " S3Path \\\n", + "0 /eodata/Sentinel-1/SAR/S6_RAW__0S/2024/05/02/S... \n", + "1 /eodata/Sentinel-1/SAR/S4_RAW__0S/2024/05/02/S... \n", + "2 /eodata/Sentinel-1/SAR/S1_RAW__0S/2024/05/02/S... \n", + "3 /eodata/Sentinel-1/SAR/S4_RAW__0S/2024/05/02/S... \n", + "4 /eodata/Sentinel-1/SAR/S6_RAW__0S/2024/05/02/S... \n", + "\n", + " GeoFootprint OriginDate \\\n", + "0 {'type': 'Polygon', 'coordinates': [[[-24.5551... 2024-05-02 20:15:12 \n", + "1 {'type': 'Polygon', 'coordinates': [[[-12.4077... 2024-05-02 20:15:29 \n", + "2 {'type': 'Polygon', 'coordinates': [[[-88.2859... 2024-05-02 13:07:28 \n", + "3 {'type': 'Polygon', 'coordinates': [[[-177.858... 2024-05-02 07:16:16 \n", + "4 {'type': 'Polygon', 'coordinates': [[[-12.6051... 2024-05-02 07:16:24 \n", + "\n", + " Attributes \n", + "0 [{'@odata.type': '#OData.CSC.StringAttribute',... \n", + "1 [{'@odata.type': '#OData.CSC.StringAttribute',... \n", + "2 [{'@odata.type': '#OData.CSC.StringAttribute',... \n", + "3 [{'@odata.type': '#OData.CSC.StringAttribute',... \n", + "4 [{'@odata.type': '#OData.CSC.StringAttribute',... " + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" } ], "source": [ @@ -90,6 +215,181 @@ "searcher.display_results(top_n=5)" ] }, + { + "cell_type": "markdown", + "id": "s2-l2a-section", + "metadata": {}, + "source": [ + "## Search Sentinel-2 Level-2A products\n", + "\n", + "Sentinel-2 Level-2A products use the CDSE product identifier `S2MSI2A`. The `query_by_filter()` API also accepts common aliases such as `L2A` or `Level-2A`, but using `S2MSI2A` makes the generated catalogue filter explicit.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "s2-l2a-search", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Number of Sentinel-2 L2A results: 5\n" + ] + }, + { + "data": { + "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", + "
IdcoverageNameS3PathGeoFootprintOriginDateAttributes
00acf34c5-4012-475a-b9fc-ab6facd1e5e420.76S2A_MSIL2A_20240524T100031_N0510_R122_T33TUG_2.../eodata/Sentinel-2/MSI/L2A/2024/05/24/S2A_MSIL...{'type': 'Polygon', 'coordinates': [[[12.56880...2024-05-24 16:59:42[{'@odata.type': '#OData.CSC.StringAttribute',...
17f85053d-4c4f-4443-944e-d3dae8116eae100.00S2A_MSIL2A_20240524T100031_N0510_R122_T33TTG_2.../eodata/Sentinel-2/MSI/L2A/2024/05/24/S2A_MSIL...{'type': 'Polygon', 'coordinates': [[[11.35495...2024-05-24 16:59:10[{'@odata.type': '#OData.CSC.StringAttribute',...
2e87a2cbc-de5f-45ed-ac9b-f5ebd208ef9c100.00S2A_MSIL2A_20240524T100031_N0510_R122_T32TQM_2.../eodata/Sentinel-2/MSI/L2A/2024/05/24/S2A_MSIL...{'type': 'Polygon', 'coordinates': [[[11.43071...2024-05-24 16:59:48[{'@odata.type': '#OData.CSC.StringAttribute',...
36ec6419b-c719-457e-af11-8cfebf83806a20.76S2A_MSIL2A_20240514T100031_N0510_R122_T33TUG_2.../eodata/Sentinel-2/MSI/L2A/2024/05/14/S2A_MSIL...{'type': 'Polygon', 'coordinates': [[[12.56880...2024-05-14 15:17:10[{'@odata.type': '#OData.CSC.StringAttribute',...
491157ce7-7067-4773-9d4d-84dfeb36f1da20.76S2A_MSIL2A_20240504T100031_N0510_R122_T33TUG_2.../eodata/Sentinel-2/MSI/L2A/2024/05/04/S2A_MSIL...{'type': 'Polygon', 'coordinates': [[[12.56880...2024-05-04 15:29:11[{'@odata.type': '#OData.CSC.StringAttribute',...
\n", + "
" + ], + "text/plain": [ + " Id coverage \\\n", + "0 0acf34c5-4012-475a-b9fc-ab6facd1e5e4 20.76 \n", + "1 7f85053d-4c4f-4443-944e-d3dae8116eae 100.00 \n", + "2 e87a2cbc-de5f-45ed-ac9b-f5ebd208ef9c 100.00 \n", + "3 6ec6419b-c719-457e-af11-8cfebf83806a 20.76 \n", + "4 91157ce7-7067-4773-9d4d-84dfeb36f1da 20.76 \n", + "\n", + " Name \\\n", + "0 S2A_MSIL2A_20240524T100031_N0510_R122_T33TUG_2... \n", + "1 S2A_MSIL2A_20240524T100031_N0510_R122_T33TTG_2... \n", + "2 S2A_MSIL2A_20240524T100031_N0510_R122_T32TQM_2... \n", + "3 S2A_MSIL2A_20240514T100031_N0510_R122_T33TUG_2... \n", + "4 S2A_MSIL2A_20240504T100031_N0510_R122_T33TUG_2... \n", + "\n", + " S3Path \\\n", + "0 /eodata/Sentinel-2/MSI/L2A/2024/05/24/S2A_MSIL... \n", + "1 /eodata/Sentinel-2/MSI/L2A/2024/05/24/S2A_MSIL... \n", + "2 /eodata/Sentinel-2/MSI/L2A/2024/05/24/S2A_MSIL... \n", + "3 /eodata/Sentinel-2/MSI/L2A/2024/05/14/S2A_MSIL... \n", + "4 /eodata/Sentinel-2/MSI/L2A/2024/05/04/S2A_MSIL... \n", + "\n", + " GeoFootprint OriginDate \\\n", + "0 {'type': 'Polygon', 'coordinates': [[[12.56880... 2024-05-24 16:59:42 \n", + "1 {'type': 'Polygon', 'coordinates': [[[11.35495... 2024-05-24 16:59:10 \n", + "2 {'type': 'Polygon', 'coordinates': [[[11.43071... 2024-05-24 16:59:48 \n", + "3 {'type': 'Polygon', 'coordinates': [[[12.56880... 2024-05-14 15:17:10 \n", + "4 {'type': 'Polygon', 'coordinates': [[[12.56880... 2024-05-04 15:29:11 \n", + "\n", + " Attributes \n", + "0 [{'@odata.type': '#OData.CSC.StringAttribute',... \n", + "1 [{'@odata.type': '#OData.CSC.StringAttribute',... \n", + "2 [{'@odata.type': '#OData.CSC.StringAttribute',... \n", + "3 [{'@odata.type': '#OData.CSC.StringAttribute',... \n", + "4 [{'@odata.type': '#OData.CSC.StringAttribute',... " + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from phidown.search import CopernicusDataSearcher\n", + "\n", + "# Example AOI around Rome, Italy. Replace with your own WKT polygon as needed.\n", + "s2_l2a_aoi_wkt = (\n", + " \"POLYGON((12.35 41.80, 12.65 41.80, 12.65 42.05, \"\n", + " \"12.35 42.05, 12.35 41.80))\"\n", + ")\n", + "\n", + "s2_l2a_searcher = CopernicusDataSearcher()\n", + "s2_l2a_searcher.query_by_filter(\n", + " collection_name=\"SENTINEL-2\",\n", + " product_type=\"S2MSI2A\", # Equivalent aliases accepted by phidown: \"L2A\", \"Level-2A\"\n", + " aoi_wkt=s2_l2a_aoi_wkt,\n", + " start_date=\"2024-05-01T00:00:00\",\n", + " end_date=\"2024-05-31T23:59:59\",\n", + " cloud_cover_threshold=20,\n", + " top=10,\n", + ")\n", + "\n", + "s2_l2a_df = s2_l2a_searcher.execute_query()\n", + "print(f\"Number of Sentinel-2 L2A results: {len(s2_l2a_df)}\")\n", + "s2_l2a_searcher.display_results(top_n=5)\n" + ] + }, { "cell_type": "code", "execution_count": null, @@ -136,9 +436,21 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3.12 (py312)", + "display_name": ".venv", "language": "python", - "name": "py312" + "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.11.14" } }, "nbformat": 4, diff --git a/phidown/config.json b/phidown/config.json index b81712a..018cc6e 100644 --- a/phidown/config.json +++ b/phidown/config.json @@ -82,7 +82,7 @@ "productGroupId": null, "lastOrbitNumber": null, "operationalMode": null, - "processingLevel": ["L1C", "L2A"], + "processingLevel": ["S2MSI1C", "S2MSI2A", "S2MSI2B"], "processingCenter": null, "processorVersion": null, "granuleIdentifier": null, @@ -268,4 +268,4 @@ "processorVersion": null } } -} \ No newline at end of file +} diff --git a/phidown/search.py b/phidown/search.py index 4b41590..e85af06 100755 --- a/phidown/search.py +++ b/phidown/search.py @@ -42,6 +42,21 @@ REQUEST_TIMEOUT_SECONDS = 30 PHISAT2_COLLECTION_NAME = "PHISAT-2" PHISAT2_COLLECTION_ALIASES = {"PHISAT", "PHISAT2", "PHISAT-2", "PHISAT_2"} +SENTINEL2_COLLECTION_NAME = "SENTINEL-2" +_SENTINEL2_LEVEL_ALIASES = { + "S2MSI1C": "S2MSI1C", + "L1C": "S2MSI1C", + "1C": "S2MSI1C", + "LEVEL1C": "S2MSI1C", + "S2MSI2A": "S2MSI2A", + "L2A": "S2MSI2A", + "2A": "S2MSI2A", + "LEVEL2A": "S2MSI2A", + "S2MSI2B": "S2MSI2B", + "L2B": "S2MSI2B", + "2B": "S2MSI2B", + "LEVEL2B": "S2MSI2B", +} _SUPPORTED_AOI_WKT_TYPES = ( "POINT", "MULTIPOINT", @@ -323,6 +338,11 @@ def _extract_date_start(value: typing.Any) -> typing.Any: return value +def _normalize_sentinel2_level_alias(value: str) -> str: + normalized = re.sub(r"[\s_-]+", "", value).upper() + return _SENTINEL2_LEVEL_ALIASES.get(normalized, value) + + class CopernicusDataSearcher: def __init__( self, @@ -477,7 +497,7 @@ def query_by_filter( if not self.burst_mode: self._validate_collection(self.collection_name) # Validate collection name only in non-burst mode - self.product_type = product_type + self.product_type = self._normalize_product_type(product_type) if not self.burst_mode: self._validate_product_type() # Validate product type (depends on collection_name and config) @@ -500,7 +520,7 @@ def query_by_filter( self.order_by = order_by self._validate_order_by() - self.attributes = attributes + self.attributes = self._normalize_attributes(attributes) if self.attributes is not None and not self.burst_mode: self._validate_attributes() @@ -556,6 +576,28 @@ def _is_phisat2_collection(self, collection_name: typing.Optional[str] = None) - candidate = self.collection_name if collection_name is None else collection_name return isinstance(candidate, str) and candidate.upper() in PHISAT2_COLLECTION_ALIASES + def _is_sentinel2_collection(self) -> bool: + return self.collection_name == SENTINEL2_COLLECTION_NAME + + def _normalize_product_type(self, product_type: typing.Optional[str]) -> typing.Optional[str]: + if self._is_sentinel2_collection() and isinstance(product_type, str): + return _normalize_sentinel2_level_alias(product_type) + return product_type + + def _normalize_attributes( + self, + attributes: typing.Optional[typing.Dict[str, typing.Union[str, int, float]]], + ) -> typing.Optional[typing.Dict[str, typing.Union[str, int, float]]]: + if not self._is_sentinel2_collection() or attributes is None: + return attributes + + normalized_attributes = dict(attributes) + for key in ("productType", "processingLevel"): + value = normalized_attributes.get(key) + if isinstance(value, str): + normalized_attributes[key] = _normalize_sentinel2_level_alias(value) + return normalized_attributes + def _phisat2_filter_from_inputs( self, *, diff --git a/pyproject.toml b/pyproject.toml index ceb8ed2..820c5c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "phidown" -version = "0.1.27" +version = "0.1.29" description = "Search and download Copernicus Data Space and PhiSat-2 data with a pythonic interface." authors = [ { name = "Roberto Del Prete", email = "roberto.delprete@esa.int; robertodelprete88@gmail.com" } diff --git a/tests/test_sentinel2_level_aliases.py b/tests/test_sentinel2_level_aliases.py new file mode 100644 index 0000000..18b3719 --- /dev/null +++ b/tests/test_sentinel2_level_aliases.py @@ -0,0 +1,75 @@ +import os + +import pytest + +from phidown.search import CopernicusDataSearcher + + +CONFIG_PATH = os.path.join(os.path.dirname(__file__), "..", "phidown", "config.json") + + +@pytest.mark.parametrize("alias", ["L1C", "Level-1C"]) +def test_sentinel2_product_type_accepts_level_1c_aliases(alias): + searcher = CopernicusDataSearcher( + config_path=CONFIG_PATH, + collection_name="SENTINEL-2", + product_type=alias, + ) + + searcher._build_filter() + + assert searcher.product_type == "S2MSI1C" + assert "Value eq 'S2MSI1C'" in searcher.filter_condition + assert alias not in searcher.filter_condition + + +def test_sentinel2_product_type_accepts_level_2a_alias(): + searcher = CopernicusDataSearcher( + config_path=CONFIG_PATH, + collection_name="SENTINEL-2", + product_type="Level-2A", + ) + + searcher._build_filter() + + assert searcher.product_type == "S2MSI2A" + assert "Value eq 'S2MSI2A'" in searcher.filter_condition + assert "Level-2A" not in searcher.filter_condition + + +def test_sentinel2_processing_level_attribute_uses_canonical_cdse_value(): + searcher = CopernicusDataSearcher( + config_path=CONFIG_PATH, + collection_name="SENTINEL-2", + attributes={"processingLevel": "L2A"}, + ) + + searcher._build_filter() + + assert searcher.attributes == {"processingLevel": "S2MSI2A"} + assert "att/Name eq 'processingLevel'" in searcher.filter_condition + assert "Value eq 'S2MSI2A'" in searcher.filter_condition + assert "Value eq 'L2A'" not in searcher.filter_condition + + +def test_sentinel2_product_type_attribute_uses_canonical_cdse_value(): + searcher = CopernicusDataSearcher( + config_path=CONFIG_PATH, + collection_name="SENTINEL-2", + attributes={"productType": "L1C"}, + ) + + searcher._build_filter() + + assert searcher.attributes == {"productType": "S2MSI1C"} + assert "att/Name eq 'productType'" in searcher.filter_condition + assert "Value eq 'S2MSI1C'" in searcher.filter_condition + + +def test_non_sentinel2_product_type_does_not_accept_sentinel2_alias(): + with pytest.raises(ValueError, match="Invalid product type: L2A"): + CopernicusDataSearcher( + config_path=CONFIG_PATH, + collection_name="SENTINEL-1", + product_type="L2A", + )