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.IMGUse 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_urlFinding 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 keysplp 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 datasetexamples 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.
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 ./hereScope
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.