How to access Sentinel-2 L1C via finder in custom AIKP

This guide explains how to access Sentinel-2 Level-1C (S2MSI1C) products from the Creodias API (STAC and OData) using OCLI’s Finder framework, how to derive a custom Finder class, and how to integrate it into a custom AIKP template (using ocli.aikp.boilerplate.sentinel_2_l1c as a reference POC).


1. Two Ways to Access Sentinel-2 L1C in an AIKP

Option A: Zero-Subclass Configuration (FinderCreodiasSentinel2)

FinderCreodiasSentinel2 (ocli/ocli/finders/finder_creodias_sentinel_2.py) supports both S2MSI2A (Level-2A, default) and S2MSI1C (Level-1C):

  • STAC (finder.provider="stac"): maps processingLevel='S2MSI1C' to collection ['sentinel-2-l1c'].

  • OData (finder.provider="odata"): queries collection ['SENTINEL-2'] with filter productType eq 'S2MSI1C'.

  • Local EODATA validation (check_product_in_eodata): detects both L2A (GRANULE/*/IMG_DATA/*/*.jp2) and L1C (GRANULE/*/IMG_DATA/*.jp2) band layouts.

If you only need standard Sentinel-2 Creodias search defaulted to L1C, set 'sat_family_name': 's2' and 'finder.processingLevel': 'S2MSI1C' in your AIKP’s TASK_DEFAULTS:

TASK_DEFAULTS = {
    ...
    'sat_family_name': 's2',
    'eodata': '/eodata',
    'master': None,
    'master_path': None,
    'finder.processingLevel': 'S2MSI1C',
}

Option B: Deriving a Custom Finder Subclass and Attaching via Template.get_finder(task)

When your custom AIKP requires custom search defaults, custom STAC/OData collection routing, custom feature parsing, or an isolated metadata cache file (.gpkg), derive a subclass from FinderCreodiasSentinel2 (or FinderCreodias / Finder) and return it from Template.get_finder(cls, task).

finder_by_task(task) (ocli/ocli/finders/__init__.py) first checks template_class.get_finder(task) before falling back to task.get_sat_family_name(), and then merges any finder.* task parameters into the returned finder’s search defaults.


2. Step-by-Step POC: ocli.aikp.boilerplate.sentinel_2_l1c

The reference implementation lives in ocli/ocli/aikp/boilerplate/sentinel_2_l1c/:

ocli/ocli/aikp/boilerplate/sentinel_2_l1c/
├── __init__.py     # Exposes class Template(TemplateBoilerplateSentinel2L1C)
├── config.py       # TASK_DEFAULTS and RECIPE_DEFAULTS for the L1C AIKP
├── finder.py       # Derived FinderCreodiasSentinel2L1C class
├── template.py     # TemplateBoilerplateSentinel2L1C with Finder lifecycle hooks
├── cli.py          # Custom CLI command (`ocli task template inspect_l1c`)
└── README.md       # Module documentation

Step 1: Derive FinderCreodiasSentinel2L1C (finder.py)

Subclass FinderCreodiasSentinel2 to set 'processingLevel' default to 'S2MSI1C', route stac_collections to ['sentinel-2-l1c'], and store cached metadata in a dedicated GeoPackage (finders/creodias_s2_l1c.gpkg) so it does not collide with L2A tasks in the same project:

import copy
import os
from typing import Optional

from ocli.core import restricted_value as rv
from ocli.finders.finder_creodias_models import LegacySearchParams
from ocli.finders.finder_creodias_sentinel_2 import (
    FinderCreodiasSentinel2,
    SatDefaultsSentinel2,
)


class FinderCreodiasSentinel2L1C(FinderCreodiasSentinel2):
    """Derived Creodias Sentinel-2 Finder pre-configured for Level-1C (S2MSI1C) products."""

    _s2_l1c_search_defaults = {
        **SatDefaultsSentinel2.default_search_params,
        'processingLevel': rv.ENUM((None, 'S2MSI1C', 'S2MSI2A'), 'S2MSI1C', 'processingLevel'),
    }

    def __init__(
        self,
        eodata_home: str,
        cache_dir: str,
        provider: Optional[rv.ENUM(("stac", "odata"), "stac")] = None,
    ):
        super().__init__(eodata_home=eodata_home, cache_dir=cache_dir, provider=provider)
        self.search_defaults.update(copy.deepcopy(self._s2_l1c_search_defaults))
        self.cache_path = os.path.join(cache_dir, 'finders', 'creodias_s2_l1c.gpkg')

    def stac_collections(self, params: LegacySearchParams) -> list[str] | None:
        level = params.processingLevel
        if not level:
            return ['sentinel-2-l1c']
        return super().stac_collections(params)

Step 2: Configure Task & Recipe Defaults (config.py)

Add sat_family_name, eodata, master, master_path, and finder.processingLevel to TASK_DEFAULTS:

from ocli.aikp.boilerplate.config import (
    RECIPE_DEFAULTS as BASE_RECIPE_DEFAULTS,
    TASK_DEFAULTS as BASE_TASK_DEFAULTS,
)

RECIPE_DEFAULTS = {
    **BASE_RECIPE_DEFAULTS,
}

TASK_DEFAULTS = {
    **BASE_TASK_DEFAULTS,
    'sat_family_name': 's2',
    'eodata': '/eodata',
    'master': None,
    'master_path': None,
    'finder.processingLevel': 'S2MSI1C',
}

Step 3: Wire the Finder Lifecycle in Template (template.py)

In your TaskTemplate subclass, implement five hooks:

  1. get_finder(cls, task): Instantiate and return FinderCreodiasSentinel2L1C.

  2. create_task(cls, task) & upgrade_task(cls, task): Call inject_finder_keys(task) so all finder.* search parameters are populated in task.config.

  3. task_set(cls, task, key_values): Resolve master (product 4-char hex ID or title) into task.config['master'] (product title) and task.config['master_path'] (relative path in eodata) via finder.get_meta_record(value).

  4. validate_task(cls, task, key): Validate eodata, master, and master_path.

  5. cli_task_mount(cls, cli_group): Mount any custom CLI commands under ocli task template.

from pathlib import Path
from typing import Dict, List

from ocli.aikp.boilerplate.template import TemplateBoilerplate
from ocli.core.exception import OCLIException
from ocli.core.logging_tools import log
from ocli.finders import finder_by_task, inject_finder_keys
from ocli.helpers.update_visible_metadata import update_visible_metadata
from ocli.project import _local_eodata_relative_path
from .config import RECIPE_DEFAULTS, TASK_DEFAULTS
from .finder import FinderCreodiasSentinel2L1C


class TemplateBoilerplateSentinel2L1C(TemplateBoilerplate):
    __version__ = 2.0
    template_recipe_conf = RECIPE_DEFAULTS
    template_task_conf = TASK_DEFAULTS
    REQUIRED = "Required by Boilerplate Sentinel-2 L1C Template AIKP"

    @classmethod
    def get_finder(cls, task) -> FinderCreodiasSentinel2L1C:
        return FinderCreodiasSentinel2L1C(
            eodata_home=task.get_eodata_home_path(),
            cache_dir=task.get_project_cache_path(),
        )

    @classmethod
    def create_task(cls, task):
        super().create_task(task)
        inject_finder_keys(task)

    @classmethod
    def upgrade_task(cls, task) -> bool:
        inject_finder_keys(task)
        return super().upgrade_task(task)

    @classmethod
    def task_set(cls, task, key_values: Dict):
        for k in ('master',):
            if k not in key_values:
                continue
            value = key_values[k]
            try:
                finder = finder_by_task(task)
                record = finder.get_meta_record(value)
                task.config[k] = record['title']
                task.config[k + '_path'] = record['product_eodata_path']
                key_values.pop(k, None)
            except OCLIException as e:
                log.warning(f'Could not resolve product {value}: {e}')

        super().task_set(task, key_values)

Step 4: Expose Custom CLI Commands (cli.py)

Use finder_by_task(task) and _local_eodata_relative_path(eodata, master_path) inside your custom CLI command to access product metadata and .SAFE files (MTD_MSIL1C.xml and GRANULE/*/IMG_DATA/*.jp2):

import click
from ocli.classes.task import Task
from ocli.cli.state import pass_task
from ocli.cli.task import ensure_task_resolved
from ocli.core import output
from ocli.core.exception import OCLIException
from ocli.finders import finder_by_task


@click.command('inspect_l1c')
@pass_task
@ensure_task_resolved
def inspect_l1c(task: Task):
    master = task.config.get('master')
    master_path = task.config.get('master_path')
    if not master:
        raise OCLIException('No master product set in task. Run `task set master=<product_id>` first.')

    finder = finder_by_task(task)
    record = finder.get_meta_record(master)

    output.info(f"Title: {record['title']}")
    output.info(f"Product ID: {record['productId']}")
    output.info(f"Processing Level: {record['processingLevel']}")
    output.info(f"EODATA Relative Path: {master_path or record['product_eodata_path']}")

3. End-to-End CLI Usage

# 1. Create a project and add an ROI
ocli create -n demo_project -a
ocli roi add -n demo_roi /path/to/roi.geojson

# 2. Create a task using the custom Sentinel-2 L1C AIKP template
ocli task create -t ocli.aikp.boilerplate.sentinel_2_l1c -r demo_roi -n s2_l1c_task --activate

# 3. Verify that finder.processingLevel defaults to S2MSI1C
ocli task show -k finder.processingLevel

# 4. Set search dates (and optionally provider: stac or odata) and query Creodias
ocli task set finder.startDate=2024-07-28 finder.completionDate=2024-08-01
ocli product load

# 5. List discovered Sentinel-2 L1C products and assign master by 4-char productId or title
ocli product list
ocli task set master=<productId>

# 6. Download product into local EODATA (when connected to EODATA storage)
ocli task get-data -m --data

# 7. Run the custom AIKP command to inspect the L1C product
ocli task template inspect_l1c