HOWTO get data from the ESA PSA

planetarypy can resolve and download products from ESA’s Planetary Science Archive (PSA) — the home of Mars Express, Rosetta, Venus Express, BepiColombo, ExoMars TGO and other ESA planetary missions that are not in the NASA PDS.

It works straight from a PDS product id: the PSA exposes an IVOA EPN-TAP service whose every granule carries a direct-download access_url, and the product id is embedded in the granule identifier — so a single lookup resolves id → download URL. No extra dependency is needed (it uses requests).

From Python

from planetarypy import psa

# Resolve a product id to its PSA download URL:
url = psa.resolve("IMA_AZ1120140041730C_ACCS01")

# Download it (by default, the file(s) directly from the PSA FTP archive):
paths = psa.fetch_psa_product("IMA_AZ1120140041730C_ACCS01")
# -> [.../IMA_..._ACCS01.LBL, .../IMA_..._ACCS01.CSV]

The downloaded files open with planetarypy.open(). By default fetch_psa_product pulls the product’s own file(s) directly from the PSA FTP archive — no zip, and none of the redundant ~10 MB per-product volume manifest the zip bundle carries. For an attached-label product (e.g. HRSC .IMG) that’s a single self-contained file; for a detached-label product (e.g. ASPERA) it’s the .LBL plus the co-located data file its ^POINTER names. Either way the result lands in the dataset’s native PDS tree under {storage_root}/psa/:

{storage_root}/psa/<DATA_SET_ID>/DATA/<volume>/<product files>

So the dataset folder is the real DATA_SET_ID, the archive’s volume sharding (e.g. 0010) is preserved, and products of the same dataset accumulate side by side in one tree — no key is needed. A per-product marker under psa/.fetched/ makes a repeat fetch return the local paths without re-downloading. Pass dest= to use a different root.

psa.fetch_psa_product("H0010_0000_GR3.IMG")
# -> {storage_root}/psa/MEX-M-HRSC-3-RDR-V4.0/DATA/0010/H0010_0000_GR3.IMG

Use direct=False to fetch the zip bundle instead (needed for PDS4 missions like BepiColombo/Juice, which have no FTP label_url; it is also the automatic fallback when a granule lacks one). The zip mirrors the same tree and adds the dataset docs (VOLDESC.CAT/AAREADME.TXT, written once); extract=False keeps the raw zip.

If a product id matches several granules (e.g. across processing levels), inspect them all:

psa.resolve_all("IMA_AZ1120140041730C_ACCS01")
# -> [{'granule_uid': 'MEX-M-ASPERA3-2-EDR-IMA-EXT4-V1.0:DATA:IMA_..._ACCS01::12.0',
#      'access_url': 'https://psa.esa.int/psa-tap/data?...'}]

For anything the helpers don’t cover, run raw ADQL against psa.epn_core:

psa.query(
    "SELECT TOP 5 granule_uid, access_url FROM psa.epn_core "
    "WHERE instrument_host_name LIKE '%ROSETTA%'"
)

Browse what’s available

The PSA serves ESA missions (Mars Express, Rosetta, Venus Express, BepiColombo, ExoMars TGO, SMART-1, …). List them — and their instruments — straight from the service (returns a pandas.DataFrame):

psa.missions()                  # missions with product counts, busiest first
psa.instruments()               # all instruments
psa.instruments("Mars Express") # instruments for one mission

# Example products for a catalog product type (mission.instrument.product_type):
psa.examples("mex.aspera.els_edr_high", n=5)   # -> product_id + access_url

Finding a catalog key

examples accepts a mission.instrument.product_type key (as well as a PSA dataset — see below). The reliable, authoritative way to find a catalog key is the catalog itself — drill down:

plp catalog list              # all missions (mex, rosetta, …)
plp catalog list mex          # that mission's instruments
plp catalog list mex.aspera   # the product-type keys

plp psa missions also shows a best-effort catalog column (the mission code, e.g. mex for “Mars Express”), but it only matches missions whose PSA name equals planetarypy’s display name — treat it as a hint, and use plp catalog list as the source of truth.

The products column is the number of individually downloadable data products (in EPN-TAP terms, “granules”) the PSA holds.

Browse entirely within the PSA (no catalog key)

The PSA browse chain stays in the PSA’s own vocabulary — mission → instrument → dataset → example products. A dataset is the PSA’s archival grouping: a PDS3 data set (DATA_SET_ID) or a PDS4 collection (LID):

psa.datasets("Mars Express", "ASPERA")   # PDS3 data sets for that instrument
psa.datasets("BepiColombo")              # PDS4 collection LIDs
psa.examples("MEX-M-ASPERA3-2-EDR-IMA-EXT4-V1.0", n=5)   # by dataset

examples accepts either a PSA dataset (from datasets) or a catalog mission.instrument.product_type key. (Planetarypy’s catalog “product type” maps to a PDS3 data set / PDS4 collection — the same thing the PSA calls a dataset.)

Discover products by geometry

To find the products you want — by viewing geometry, illumination or location — build a dataset’s geometry index. Each PSA dataset ships a per-product PDS3 geometry table (GEO_*.TAB) with columns EPN-TAP doesn’t expose: INCIDENCE_ANGLE, EMISSION_ANGLE, PHASE_ANGLE, SOLAR_LONGITUDE (Ls), ORBIT_NUMBER, CENTER_LATITUDE/LONGITUDE, HORIZONTAL_PIXEL_SCALE, … — geometry_index parses and concatenates them into one filterable DataFrame:

gi = psa.geometry_index("MEX-M-HRSC-3-RDR-V4.0")   # base + EXT1–9, aggregated
# filter on any column to find product ids, then fetch them:
sel = gi[(gi.INCIDENCE_ANGLE < 60) & gi.CENTER_LATITUDE.between(-50, -40)]
for pid in sorted(sel["PRODUCT_ID"].unique()):
    psa.fetch_psa_product(pid)

A PSA dataset is split into mission-phase volumes (a base DATA_SET_ID plus -EXT1…-EXT9); geometry_index unions the whole group by default (pass any member id — dataset_group/group_members expose the grouping). Each member is parquet-cached under {storage_root}/psa/.indexes/, so the (large) first build is paid once. Pass aggregate=False for a single dataset, force=True to rebuild.

Note

The geometry table samples geometry along each image footprint, so it has many rows per product — filter, then take PRODUCT_ID.unique().

From the command line

# Browse the PSA: missions -> instruments -> datasets -> examples
plp psa missions
plp psa instruments "Mars Express"
plp psa datasets "Mars Express" ASPERA
plp psa examples MEX-M-ASPERA3-2-EDR-IMA-EXT4-V1.0 -n 5

# ...or jump in with a catalog key (from `plp catalog list`):
plp psa examples mex.aspera.els_edr_high -n 5

# Discover products by geometry (preview columns, or dump matching product ids):
plp psa geometry MEX-M-HRSC-3-RDR-V4.0 -c "PRODUCT_ID,INCIDENCE_ANGLE,SOLAR_LONGITUDE"
plp psa geometry MEX-M-HRSC-3-RDR-V4.0 --pids        # unique PRODUCT_IDs, one per line

# Resolve a single product id to its download URL (--all shows every match):
plp psa resolve IMA_AZ1120140041730C_ACCS01
plp psa resolve IMA_AZ1120140041730C_ACCS01 --all

# Download the product's file(s) directly into {storage_root}/psa/<DATA_SET_ID>/...:
plp psa fetch IMA_AZ1120140041730C_ACCS01

# Force the zip bundle instead (PDS4 missions; or keep the raw zip):
plp psa fetch IMA_AZ1120140041730C_ACCS01 --zip
plp psa fetch IMA_AZ1120140041730C_ACCS01 --zip --no-extract --dest ./here

Scope

This targets the ESA PSA (psa.esa.int). For NASA-archived products use the catalog/index fetchers or planetarypy.search (the NASA PDS registry). The two archives are complementary — PSA holds the ESA missions, the PDS holds the NASA ones — and both resolve products behind the same access_url idea.