All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
-
body_crs(body, system="ocentric")returned a sphere. The geographic offset table read{"ocentric": 0, "ographic": 1}, but IAU offset 0 is"<Body> (2015) - Sphere / Ocentric"— for Mars that isIAU_2015:49900witha == b == 3396190. The real ocentric ellipsoid is offset 2 (49902,b = 3376200) and was unreachable through the API entirely. The projected table twenty lines below had the same triple correct all along.systemnow takes"sphere","ographic"and"ocentric"mapping to +0/+1/+2, and bodies with no ellipsoid (Moon, Venus, Europa) raise rather than silently handing back the sphere.The default is unchanged:
body_crs("mars")still returns the sphere, now spelledsystem="sphere". Spheres are the working currency in planetary practice — ISIS operates on them, many published products use them, and a shared sphere avoids datum-shift surprises when stacking heterogeneous data in GIS. What changed is that it no longer arrives mislabelled.get_crs's"default"likewise still resolves to the sphere.
-
planetarypy.units— a project-wide switch for astropy units, with the same setter / context-manager pair as the target CRS:units.set_units(False)for a session,with units.use_units(False):for a block. Units are on by default, matching whatconstantsalready did. Previously the only precedent was a private_maybe_quantityin the SPICE layer that no caller could reach. -
nomenclature.find(body, name)— the IAU record for one named feature, so coordinates are looked up rather than remembered. The value commonly quoted for Jezero sits ~17 km east of the gazetteer's. RaisesLookupErrorwhen absent andValueErrorwhen ambiguous rather than silently returning the first match;features(name=...)does the same filtering for frames. -
Nomenclature results carry their units. Numeric columns are documented in
.attrs["units"]on every returned frame, andfindreturns astropy quantities fordiameterand the lat/lon fields, subject to the toggle above. Columns stay float dtype on purpose — wrapping a whole column would make it object dtype and lose vectorised maths. -
planetarypy.nomenclature— IAU-approved surface feature names as a plottable layer, from the USGS Gazetteer of Planetary Nomenclature (47 bodies; Mars 2049 features, the Moon 9086).features()fetches and caches per body and filters by feature class, diameter and bounding box;add_features(ax, body)is thecoastlines()move, drawing onto axes you already have and reading their limits by default. Feature extents are drawn by default from the gazetteer's min/max lon/lat columns — a centre point says a name is nearby, the box says whether your footprint actually covers it — and labels are decluttered, largest feature first. The gazetteer ships ESRI authority codes (Mars isESRI:104905); results reproject to an IAU CRS and say so. Needs the[geo]extra. -
A session-wide target CRS.
crs.set_target_crs(...)fixes one frame for the rest of a session, so work that mixes a USGS gazetteer shapefile (ESRI authority), a HiRISE GeoTIFF (IAU_2015) and PSA footprints stays consistent without restating the CRS at every call.crs.target_crs(...)is the context-manager form — it nests, and restores the previous setting even if the block raises. Backed by aContextVarrather than a module global, so threads and asyncio tasks don't stamp on each other. -
crs.resolve_crs(explicit, fallback=...), the single place precedence is decided: an explicit argument beats the session target, which beats the caller's fallback (usually the body's own IAU CRS). One implementation means every consumer resolves identically. -
crs.announce_conversion(...)andCRSConversionWarning. A reprojection the caller did not ask for now says so, naming both authorities. Silent reprojection is how an authority mismatch becomes quiet wrongness. It is awarnings.warnrather than a log line on purpose: planetarypy disables its loguru logger by default for library use, so alogger.infowould be invisible to exactly the people who need to see it. The dedicated category means it can be silenced on its own.
A slimming release: MRO HiRISE and CTX behaviour now ships as separate distributions, so core carries no instrument-specific code and each instrument versions independently.
Cut in coordination with planetarypy-hirise 0.1.0 and planetarypy-ctx 0.1.0, which require this version specifically: the extensibility fix below is what makes them reachable at all, and no earlier release contains it.
- HiRISE and CTX extracted into
planetarypy-hiriseandplanetarypy-ctx. Import paths are unchanged —planetarypyextends its own__path__viapkgutil.extend_path, andinstruments/andmro/are PEP 420 namespace dirs, sofrom planetarypy.instruments.mro.hirise import get_browsekeeps working once the package is installed. Install via the new[hirise],[ctx]or[instruments]extras. plp --helpgroups commands by their source. Core verbs keep their existing panels and render first; each plugin's verbs follow under panels of their own (HiRISE · Fetch & download,CTX · Visualize). Core applies the prefix from the plugin's declared panel name, so whatever grouping a plugin chose for itself survives inside its section.- Core keeps the declarative catalog knowledge. The
INDEX_REGISTRYentries formro.hirise.{edr,rdr,dtm}andmro.ctx.edr, and the mission/instrument name maps, stay here — so a core-only install still runsplp indexes peek mro.hirise.edr,plp fetch mro.hirise.edr, tab-completes obsids and finds both instruments in the catalog, without either package.
- Plugin contribution manifests. A CLI plugin may declare a
CONTRIBUTESdict naming the commands, storage resolvers and meta handlers it intends to provide; core verifies every entry onceregister(app)returns and reports anything missing on stderr. Entry-point loading alone cannot catch a plugin that imports and registers without error while contributing nothing — which is exactly howplanetarypy-ctxbehaved while its package resolved to an empty namespace directory. The manifest is optional; plugins without one load unchanged.
- Sibling distributions can now actually extend
planetarypy.planetarypy/__init__.pymakes the top level a regular package, which pinned__path__to core's own directory and left everyplanetarypy/instruments/mro/...subtree shipped by a plugin unreachable — regardless ofinstruments/andmro/being namespace dirs.pkgutil.extend_pathrestores extensibility at the level that was actually blocking it, for ~0.13 ms against an ~84 ms import.
instruments/mro/hirise.pyandinstruments/mro/ctx/, thehibrowse/hiedr/himos/hitif/ctxqv/ctx-migrateverbs and their completion helpers, and_parse_ccds(its only callers werehiedrandhimos; the HiRISE package carries its own copy rather than coupling to a private core symbol).- The hard-coded HiRISE fallback in
pds/meta_display.get_handler, and both_STORAGE_RESOLVER_MODULESlazy entries. Core no longer names any instrument module: the packages self-register throughregister_meta_handlerandregister_storage_resolverwhen their CLI plugin is loaded atplpstartup.
Makes our traffic identifiable to the archives we depend on, so their operators can help us when something breaks on their side.
-
utils.user_agent()andutils.headers(), now sent on every outbound request. Previously onlyget_remote_timestampset a User-Agent;check_url_existsandurl_retrieve— the path every kernel and index download actually takes — went out aspython-requests/2.34.2, indistinguishable from any other script on the internet. NAIF asked which requests were ours while debugging connection timeouts against our CI, and there was nothing for them to grep. Requests now carryplanetarypy/<version> (+https://github.com/planetarypy/planetarypy). Unlike an egress IP this survives the address rotation GitHub-hosted runners do on every job, which makes it the more durable identifier of the two.The version is read from
planetarypy.__version__on first use, not fromimportlib.metadata— the latter reports installed distribution metadata, which goes stale in an editable install and would advertise a version the running code isn't. -
The runner's egress IP is recorded in the network CI workflows (
NAIF download smoke,PDS download smoke,Archive reachability), alongside a UTC timestamp and the run URL, in the job summary. Always-run, so a failing job records it too. GitHub-hosted runners take a fresh address per job, so there is no static answer to give an archive operator — what helps is the address from the run that actually failed.
A diagnostics release, prompted by a WUSTL permission change that 401'd the whole MSL SAM dataset: two failures that used to be silent or misattributed now say what actually happened, and a single node outage can no longer red the release gate.
- A failed index download no longer reports a missing parquet file.
Index.download()logged the exception and returned, leaving callers to carry on and use files that were never written — so an upstream HTTP 401 surfaced asFileNotFoundError: .../l0_index.parqfrompandas.read_parquet, three layers from the cause, with the explanatory log line invisible because library logging is disabled by default. It re-raises now, and you get the URL and the status code. All three call sites (get_index's refresh path,ensure_parquet,plp indexes refresh) already assumed success. - Losing the Kakadu accelerator in
plp hitifis now visible. The fallback to GDAL's OpenJPEG decoder announced itself throughlogger.info, which the library disables by default — so a machine withoutkdu_expandsilently ran ~3.5× slower (81 s versus 23 s on a full RED product), single-threaded, with nothing on screen to explain it. It prints to stderr alongside the other echoed commands, and says what it costs.
- The index round-trip gate test tries three hosts instead of one. It guards the pyarrow declared-dependency contract and needs a small index, not a particular one; pinning it to
msl.sam.l0meant a single WUSTL outage blocked releases through a test that has nothing to do with WUSTL. It now walkscassini.rss.profile_index(SETI) →msl.cmn.rdr(WUSTL) →mgs.moc.rdr(JPL), ordered by measured payload, and skips only when every host fails transiently. The happy path got cheaper too — 0.01 MB against the previous 0.24 MB.
- A six-hourly archive reachability probe (
Archive reachabilityworkflow). OneHEADper registered index URL covers all 79 across 6 hosts in about 7 seconds, so the three nodes whose smallest cumulative index is 100 MB+ — which the download canary cannot afford — are watched too. Per-URL rather than per-host, because a host can serve most of its archive while single objects are unreadable. Results publish to an orphanstatusbranch that README badges read through shields.io; nothing is committed tomain. Its first run found seven broken URLs: five WUSTL 401s and two SETI 404s that look like stale registrations on our side. - Self-closing GitHub issues for unreachable archives. One issue per affected host, listing which index URLs fail with which status, closed automatically with the outage duration when the host recovers — so an open
upstream-outageissue always means "still broken right now".
A HiRISE-to-GeoTIFF release: projected HiRISE JP2s become GeoTIFFs carrying an official IAU 2015 code instead of the ISIS-style CRS the PDS ships, bit-exactly and roughly 3.6× faster than a plain rio warp. Every external command is echoed as it runs, so the equivalent rio one-liner is always on screen.
plp hitif/planetarypy.instruments.mro.hirise.jp2_to_geotiff()— convert a projected HiRISE JP2 to a GeoTIFF in an official IAU 2015 CRS. This is a real reprojection, not a relabel: HiRISE polar RDRs are built on a sphere of Mars' polar radius (R=3376200), which no IAU 2015 code describes, so stampingIAU_2015:49930onto the existing grid would displace the image by ~1.8 km. Between two ocentric spheres sharinglat_0/lon_0/kthe transform is a pure uniform scale, so the warp lands on an exactly 1:1 pixel grid and the output is bit-exact against the JP2 (verified per-pixel over all 804,859,200 px ofESP_081720_2650_RED). The target code is auto-detected from the source projection — polar stereographic by hemisphere, equirectangular by central meridian — or forced with--iau-code.- Kakadu as an optional decode accelerator. GDAL's OpenJPEG decode is ~89% of the wall clock on a full RDR and barely threads (
GDAL_NUM_THREADS=ALL_CPUSbought 0.8 s of 70.5 s, inside the noise floor). Whenkdu_expandis onPATHit is used instead — 4.2 s versus 70.5 s, a 16.9× decode speedup, verified bit-identical — via aVRTRawRasterBandover its flat output. Without it the JP2 goes straight torio warp, so nothing depends on a commercial decoder. End to end: ~23 s versus ~83 s. planetarypy.crs.projected_crs(body, projection, system)— resolve a body's standard projected CRS from the IAU 2015 authority, extendingbody_crs'snaif_id*100 + offsetconvention to the 17 projected variants (north_polar,equirectangular,mercator, …). Note the system triple differs from the geographic one: for projected codes slot 0 is the sphere and ocentric moves to slot 2.- Defaults chosen from measurements, not convention. Tiles are 1024 rather than GDAL's 256 (2.4 MB smaller on a RED product),
PREDICTORis deliberately not set (it makes tiled HiRISE output 5.7 MB larger),nodata=0is tagged because HiRISE reserves 0 for null and an untagged product reportsVALID_PERCENT=100while being 62.8% background, and an 8-level overview pyramid (average) is built so an 800 Mpix raster is pannable. Each is individually overridable;--no-nodataand--no-overviewsopt out. - Per-band
ColorInterpis restored after warping.rio warpdiscards it, so a COLOR/IRB product would otherwise open as three unrelated grey bands; a singlerio edit-infocall puts the RGB tagging and the nodata value back without touching a pixel.
- PSA geometry index 404 for slash-containing
DATA_SET_IDs.plp psa geometry(andplanetarypy.psa.geometry_index) 404'd for a dataset whose id contains a slash — e.g.VEX-V-VIRTIS-2/3-V3.0. PSA's FTP directory names encode that slash as a dash (VEX-V-VIRTIS-2-3-V3.0), but the index-URL builder split the label URL on the raw-slash form, which never matched — so it kept the whole deep product URL and appended a bogusINDEX/path. The slash is now encoded as a dash when building the path, and kept out of the local parquet/tmp cache paths (where it had been silently creating nested directories).
A COG-browsing release: an interactive, zero-install web viewer for cloud-optimised GeoTIFFs — shipped as a documentation tab and launchable from Python — plus a STAC "unpacker" to see what a collection holds, and a full remote-rasters tutorial.
- Interactive COG Browser (documentation tab). A self-contained, static web viewer that streams a Cloud-Optimized GeoTIFF straight from a URL — no download, no server-side tiling — and renders it in the COG's native planetary projection (any CRS via proj4), streaming overviews by HTTP range request. Features: colormaps (gray/inverted/viridis/magma/inferno), min/max + gamma stretch on the GPU with auto-restretch after pan/zoom (Manual toggle + one-shot "Restretch to view"), histogram equalization on the raw 16-bit DN (adaptive CLAHE with a configurable tail clip, or global) with an optional strip-flatten pre-step, a zoom bar, scale bar, and an editable lat/lon readout. NODATA and extent/native-resolution/stretch are auto-derived from the COG. Published as the COG Browser navbar tab; built-in presets for the Robbins south-polar and equatorial (MC08–23) CTX mosaics and the FU Berlin / DLR HRSC level-3 global mosaic, plus any COG via
?cog=<url>&crs=<proj4>. datasets.browse(source)— open the COG Browser on aRemoteRaster,StacItem, registry key, bare COG URL, or a viewer preset. It resolves the COG URL and its projection (proj4 generated by pyproj — from the registry's IAU authority code, or read from the COG itself) into the viewer URL.RemoteRaster.browse(),StacItem.browse(), andStacCollection.browse(lon, lat)are the fluent forms.- STAC collection unpacker.
StacCollection.items(limit=)lists a collection's contents (its items/products) with no spatial filter — the "unpacker".planetarypy.datasets.stac_collections(stac_url)discovers every collection a STAC endpoint offers (id, title, description), andstac_items(collection)is the module-level form. These complement the existing location-basedsearch/at. - Remote-rasters tutorial (
docs/tutorials/datasets_tutorial.qmd) — a worked walk-through of the registry, reading lon/lat windows from a COG (registry raster and bare URL, anchors, GeoTIFF output), the STAC workflow (discover → unpack → search → read), andbrowse().
A fetch fast-path release: repeated and batch downloads stop re-reading the same PDS index, the single-product CLI path resolves once instead of twice, and instrument storage resolvers can reuse what resolution already found.
- In-process index caching.
planetarypy.pds.get_indexnow memoizes the loaded frame per dotted index key for the life of the process, so a batchplp fetchor a notebook session reads each parquet once instead of on every lookup. Callers always get a distinct frame (pandas Copy-on-Write keeps that cheap), so mutating a returned index can't corrupt the cache. Newplanetarypy.pds.clear_index_cache(dotted_index_key=None)drops cached frames when you need a reload. DownloadedProduct.file_urls.fetch_productnow carries the resolved filename → URL map on its result, so callers (and the CLI) don't have to resolve a second time just to show or record where files came from.StorageContextresolver contract.register_storage_resolvernow also accepts a resolver taking a singleStorageContext(mission, instrument, product_type, product_id, and theResolvedProductfrom the resolution that just happened). The existing(product_type, product_id)form keeps working — the form is detected from the callable's arity.ResolvedProduct.metaexposes the matched index row so a resolver can reuse fields (e.g. a PDS volume) instead of re-reading the index. See the instrument-packages guide.
plp fetch <KEY> <PID>resolves once. The single-product path previously resolved the product twice — once to print its URL, once to download it. It now resolves once and reports the URL from the result. Combined with index caching, a warm-cache single fetch drops from ~0.28 s to ~0.01 s, and the CTX EDR local-path lookup no longer triggers a third full index read.
A geospatial-discovery release: search the PDS registry by area, ask "what data is at this coordinate?" from the terminal, and read lon/lat windows straight out of remote cloud-optimised GeoTIFFs.
- Spatial search of the NASA PDS registry.
planetarypy.search_products(bbox=(west, south, east, north))filters by footprint overlap via the registry'scart:Bounding_Coordinatesfields (degrees; shapely/GeoJSON order).planetarypy.search.bbox_from_point(lon, lat, radius_deg)builds a box around a point, andplanetarypy.search.count(**filters)returns the number of matching products without fetching any rows — handy to size a query first. Spatial fields are only populated where the archive added them (common for derived/calibrated products, often absent for raw/EDR), and a footprint crossing a pole or the anti-meridian can have a degenerate bounding box. plp search at BODY LON LAT— "what PDS data exists at this coordinate?" from the command line, with--radius,--instrument,--count, and--limit.BODYis a planet name (mapped to its target LID) or a full target LID; negative coordinates are accepted as positionals.planetarypy.datasets— body-namespaced access to remote reference rasters. A registry of non-PDS institutional mosaics/DEMs read by lon/lat window straight from cloud-optimised GeoTIFFs over HTTP, with no full download. Two kinds:RemoteRaster(one fixed global COG — the FU Berlin / DLR HRSC level-3 mosaic) andStacCollection(a STAC collection of many COGs resolved by location — the USGS Astrogeologymo_themis_controlled_mosaicsandmro_ctx_controlled_usgs_dtmsfor Mars,lunar_orbiter_laser_altimeterfor the Moon).read_window(lon, lat, size, anchor="center"|"sw"|"nw"|"se"|"ne")andread_bbox(west, south, east, north)accept aRemoteRaster, aStacItem, a registry key or a bare COG URL, transform the box into the file's own CRS via pyproj (so any body/projection works), and return a georeferenced rioxarrayDataArray(or write a GeoTIFF without=). Access is by body:datasets.mars.hrsc_level3,datasets.moon.lola_dtms, …. This is the first slice of theplanetarypy.datasetsdesign; a remote-refreshed registry, a download mode, and aplp datasetsCLI are planned.
- PDS registry search: multi-filter queries no longer fail with HTTP 400.
planetarypy.searchbuilt its query string by joining clauses withand, but the NASA PDS registry API requires the whole query wrapped in outer parentheses — so any search combining two or more filters (e.g.target+observationals, or a spatial bounding-box) returned400 UnparsableQParamExceptionand only single-clause lookups worked. The query builder now wraps the joined clauses in(...), matching whatpds.peppidoes. This also unblocks spatial searches via thecart:Bounding_Coordinates.cart:*_bounding_coordinatefields. - Archived SPICE metakernels with non-
'./data'path conventions now load.archived_kernels.get_metakernel()rewrote kernel paths by matching the literal'./data'inPATH_VALUES, which most NAIF archives use — but some (e.g. Hayabusa2's PDS4 archive) ship'..'instead. The rewrite silently no-opped on those, leaving an unresolvable relative path that brokefurnsh. The rewrite is now convention-agnostic: it repoints whatever value thePATH_VALUESblock holds to the absolute local kernel directory, leavingKERNELS_TO_LOADsymbol references intact. plotting.add_sun_indicatorno longer rescales the image. The sun-direction glyph was drawn in data coordinates anchored in a corner; for any azimuth pointing past that corner, plotting the off-bounds marker triggered matplotlib autoscale, adding whitespace and pushing the indicator outside the frame. It is now drawn in axes-fraction coordinates with an inset anchor, so it stays inside the panel for any azimuth and never touches the data limits (this also removes a latentorigin='upper'-only assumption).
plotting.add_sun_indicator/imshow_with_sunare marked experimental. They now carry an experimental note and emit a one-shotUserWarning. The azimuth-convention handoff is not yet validated end-to-end: these helpers expect azimuth clockwise-from-image-top (PDSSUB_SOLAR_AZIMUTH), whereasSpicer.solar_azimuth_atreturns clockwise-from-north — they agree only when image-north points up.
- Graceful error when the optional
[spice]extra is missing.spiceypyandscipyship in the[spice]extra rather than core, so a barepip install planetarypydoes not have them — yet importingplanetarypy.spice(orplanetarypy.spice.spicer) failed with a bareModuleNotFoundError: No module named 'spiceypy'that gave no hint about the fix. SPICE imports now route through a small guard that raises a clearImportErrortelling the user topip install 'planetarypy[spice]'(the conda package ships SPICE by default). Core is unaffected —planetarypyitself never imports SPICE, and thescipyimport inspicer.pystays lazy.
- Declare
pyarrowas a core dependency. The PDS index cache writes and reads its parquet files through pandas (to_parquet/read_parquet), which needs a parquet engine that pandas does not bundle.pyarrowwas never declared, so a cleanpip install planetarypyraisedImportError: Unable to find a usable enginethe first time any index was downloaded or read back. The gap went unnoticed because the CI test gate installs its dependencies from conda-forge, which pullspyarrowin transitively and masked the missing declaration that pip-based installs — and real users — actually hit; the new pip-based daily PDS-download smoke canary surfaced it. Now declared in both the PyPI core dependencies and the conda recipe.
plp constants list— browse the constants registry by category. A discovery surface alongside the existingplp constants <body>/plp constants <body>.<field>value queries. Bareplp constants listprints the category menu with body counts;plp constants list <category>lists that category's bodies (name, NAIF id, class, parent); andplp constants list moons <planet>restricts to one planet's satellites (e.g.plp constants list moons saturn). Categories areplanets,moons,asteroids,comets,dwarf_planets,mission_visited,sun— overlapping by design (Pluto is bothplanetanddwarf_planet). Each wraps the matching discovery helper already exported fromplanetarypy.constants(planets(),moons(of=…),asteroids(), …), so the CLI stays a thin layer over the Python API. The existing body-query forms are unchanged. Documented indocs/howto/cli.qmd.
planetarypy.psa— resolve & download ESA PSA products. A new module over ESA's Planetary Science Archive EPN-TAP service (psa.epn_core):resolve(product_id)returns a direct download URL by substring-matching the product id against the granule identifier (ADQLLIKE, no per-instrument rules, no harvest);fetch_psa_product(product_id)downloads and unpacks the product (a zip of label + data, openable withplanetarypy.open); plusresolve_all, a low-levelquery, and discovery helpersmissions()/instruments(mission=None)(with downloadable-product counts) /examples(key, n)(example products for a catalogmission.instrument.product_typekey — bootstraps the PSA dataset from the catalog, so no mission-name translation is needed).fetch_psa_productunpacks the PSA zip faithfully under{storage_root}/psa/, so the dataset folder is the realDATA_SET_IDand the archive's volume sharding is preserved (psa/<DATA_SET_ID>/DATA/<volume>/<files>); products of the same dataset accumulate in one tree, dataset-level docs are written once, the zip's owninventory.txtmanifest is dropped, and a per-product marker underpsa/.fetched/makes a repeat fetch return local paths without re-downloading.resolve/resolve_allorder matches bygranule_uidso a product id that occurs in several datasets (e.g. across processing levels) resolves deterministically. By defaultfetch_psa_productpulls the product's file(s) directly from the PSA FTP archive via the granule'slabel_url(no zip, none of the ~10 MB redundant per-product volume manifest the bundle carries): an attached-label product is one self-contained file; a detached-label one is the.LBLplus the co-located data file its^POINTERnames.direct=False(CLIplp psa fetch --zip) forces the zip bundle instead — required for PDS4 missions that have nolabel_url, and the automatic fallback when a granule lacks one. Geometry-based product discovery:geometry_index(dataset)builds a filterable table from a PSA dataset's per-product PDS3 geometry tables (GEO_*.TAB, elseINDEX.TAB) — columns EPN-TAP doesn't expose (INCIDENCE_ANGLE,SOLAR_LONGITUDE,ORBIT_NUMBER,CENTER_LAT/LON,HORIZONTAL_PIXEL_SCALE, …), parsed with the standardpds.IndexLabelreader. A PSA dataset is split into mission-phase volumes (base +-EXT1…-EXT9);geometry_indexunions the whole group by default (dataset_group/group_membersexpose the grouping, version-preserved), each member parquet-cached under{storage_root}/psa/.indexes/. Filter the result to findPRODUCT_IDs and hand them tofetch_psa_product. Newplp psa geometry KEY [-c COLS] [--pids] [--no-aggregate] [--force].datasets(mission, instrument=None)lists a mission/instrument's PSA datasets (PDS3 data sets or PDS4 collections); mission/instrument name filters are case-insensitive.examplesaccepts either a PSA dataset (fromdatasets) or a catalog key, so the PSA browse chain — missions → instruments → datasets → examples → fetch — stays entirely in the PSA's own vocabulary (no catalog key, no name bridge). Newplp psa resolve|fetch|missions|instruments|datasets|examples.plp psa missionsincludes a best-effortcatalogmission-code column to bridge PSA's display names (e.g. "Mars Express") to catalog keys (mex); the authoritative way to discover keys isplp catalog list <mission>[.<instrument>]. Uses onlyrequests(no new dependency). This is the first archive provider behind the cross-archive resolver contract (identity → access_url | NotResolvable); ESA products resolve straight from a bare product id (validated on Mars Express). SeePlans/fetchability_strategy.md.
Three additions: open any product in one call, search the whole NASA PDS, and a new extension seam so instrument-specific code can live in its own packages.
-
planetarypy.open(path)— open any planetary data product in one call. A new top-level opener returns the product in memory: PDS3 (.IMG/.LBL), PDS4, FITS, and similar come back as a dict-like handle (d.keys(),d["IMAGE"]→ numpy array,d["INDEX_TABLE"]→ pandas DataFrame,d.metaget("KEY")→ label metadata); already-projected GeoTIFFs and ISIS.cubfiles come back as a georeferencedxarray.DataArray. Routing is automatic from the file type and overridable withprojected=True/False.planetarypy.readis apandas.read_*-style alias. New moduleplanetarypy.io. -
Download and open in one step.
DownloadedProduct.open()opens a fetched product (preferring its PDS label), andcatalog.fetch_product(..., open=True)returns the opened object directly instead of theDownloadedProduct. -
plp open PATH— CLI verb that opens a product and prints what's inside (--showdisplays the default image). New how-to:docs/howto/opening_data.qmd.The reader engine is MillionConcepts'
pdr, now a core dependency so opening works out of the box — end users never need to install or import it directly. (pillowis pulled in alongside for image arrays.)
planetarypy.search_products(...)queries the PDS Engineering Node's registry-wide search API (80M+ products across all NASA missions) and returns apandas.DataFrame(one row per product, indexed by LIDVID). Filters:target,instrument,instrument_host,investigation,processing_level,before,after,observationals,lidvid, plus a rawqueryescape hatch. This reaches products thecatalog/indexessubsystems can't resolve — much of Cassini, Voyager, Magellan, etc.planetarypy.fetch_pds_product(lidvid)— download a registry product's files (data + label) by LIDVID into{storage_root}/pds_search/; the files open directly withplanetarypy.open(). Plusget_product(lidvid)andproduct_file_urls(...)helpers.plp searchCLI sub-app:plp search products,plp search get LIDVID,plp search fetch LIDVID.- New optional
[search]extra (pip install "planetarypy[search]") providing NASA'spds.api-client. Kept out of core; core stays Python ≥3.11. New how-to:docs/howto/pds_search.qmd. We wrappds.api-clientdirectly rather thanpds.peppi, which requires Python ≥3.12, hard-pinspandas~=2.2.3(blocking pandas 3.x), and pullsfastmcp. The search covers the NASA registry only — not non-NASA national archives such as Chang'e (CNSA) or Chandrayaan-2/3 (ISRO).
The first phase of moving instrument-specific code (HiRISE, CTX, Galileo SSI) out of core into standalone packages: core now exposes a stable contract those packages plug into, while staying general. No behavior change in this release.
- Registration hooks:
catalog.register_index(mission, instrument, product_key, IndexConfig),catalog.register_storage_resolver(key, fn)(promoted to public),catalog.default_product_dir(...), andpds.register_meta_handler(index_key, fn)— so a package can register its index config, storage layout, andplp metarendering at import. - CLI plugin seam:
plpnow discoversplanetarypy.cli_pluginsentry points at startup and registers each package's Typer sub-app, so instrument verbs appear under the unifiedplpwhen their package is installed. A failing plugin is skipped with a warning. planetarypy.io.read_image— the projected-raster reader is now public inplanetarypy.io(it wasinstruments.utils.read_image, kept as a back-compat re-export).- New contributor guide:
docs/explanation/instrument_packages.qmd.
url_retrieveno longer fails on Windows when finalizing a download. The downloader wrote to a temporary.partfile viatqdm.wrapattr(open(...), ...), whose context manager closes the progress bar but not the wrapped file handle. On POSIX the rename-into-place tolerated the lingering handle, but on Windows it raisedPermissionError: [WinError 32] The process cannot access the file because it is being used by another process. The scratch file is now opened in its own context so its handle is released before the rename. This surfaced as awin_64build failure when importingplanetarypy.constants(which lazy-downloads the NSSDC archive at import time), but affected any download on Windows.
- Removed the non-functional
yearparameter fromplanetarypy.crs(body_crs,local_crs,get_crs). PROJ ships only theIAU_2015CRS authority — there is noIAU_2009/IAU_2006/… — so anyyearother than 2015 always raised. (The multi-edition "time travel" inplanetarypy.constantsis a separate, PCK-based feature; the CRSyearparam was a mistaken mirror of it.) The functions now build againstIAU_2015directly; callers using the old defaultyear=2015are unaffected.
Lighter SPICE defaults and a leaner, more robust CI.
- Default planetary ephemeris is now
de432sinstead ofde430. The generic SPICE kernel set (fetched bySpiceranddownload_generic_kernels) uses de432s (~10 MB) rather than de430 (~120 MB).⚠️ This narrows the default valid date range from 1550–2650 to 1950–2050 — ample for modern spacecraft-era geometry, at a ~12× smaller download. Need the wider range? Fetch the full de430 on demand:download_generic_kernel("de430")thenspiceypy.furnshit (the"de430"alias is retained). The spice tutorials note this.
- Release-gate CI downloads cut from ~330 MB to ~20 MB and hardened.
test_spicer.TestSolarAzimuthnow validates against embedded, immutable HiRISE reference rows instead of downloading the ~200 MB RDR cumulative index, so the RDR prefetch step is removed; the (now ~20 MB, de432s) kernel prefetch got exponential-backoff retries so a transient NAIF blip no longer false-reds a release. - New PDS-node availability canary. A scheduled
PDS download smokeworkflow fetches a small index from several PDS nodes (geosciences ~0.2 MB, ring-moon ~10 MB, HiRISE ~7 MB) so an outage or download/parse regression surfaces. The smokes areslow-marked (excluded from the release gate), so the canary alerts without blocking releases.
A planetary-CRS module plus anti-meridian geometry — planetarypy now owns shared coordinate-system handling (the craterpy CRS hand-off).
planetarypy.crs— IAU planetary coordinate reference systems viapyproj, adapted from Christian Tai Udovicic's craterpycrs.py:body_crs(body, system="ocentric")— a body's geographic CRS from the IAU 2015 authority.bodyis a name (resolved viaplanetarypy.constants) or a NAIF id. The ellipsoid/radii come from the IAU code itself — nothing looked up or hardcoded.local_crs(lon, lat, body)— Azimuthal-Equidistant CRS centered on a point (built on the body's IAU geodetic CRS), for feature-centered / distance-true work like crater annuli.get_crs(body, system="default")— craterpy-compatible alias.- Generative (any IAU body) rather than a static registry; drops craterpy's exception-driven dispatch and proj4 string-surgery for planetographic.
- Anti-meridian helpers in
planetarypy.geo(ported from the ganymede project):split_at_antimeridian(corners)— no-cross → one polygon, ±180° crossing → two hemisphere polygons, pole-containing → a cap (via theantimeridianpackage); andnormalise_lon_bounds(lon_min, lon_max)— distinguishes an antimeridian wrap from a prime-meridian wrap for bbox filtering. antimeridianadded to core dependencies (small, pure-Python).- New tutorial
docs/tutorials/planetary_crs_tutorial.ipynb. Tutorial pages now also offer their source as a downloadable notebook (Quarto "Other Formats").
Adds GDAL-native projected-raster geometry to geo.py, removes a broken unused module, and migrates linting to ruff.
- Projected-raster geometry helpers in
planetarypy.geo— format-agnostic and ISIS-free, reading CRS/footprint/overlaps straight from the raster via rasterio, so they work on ISIS.cubtoday and GeoTIFF after ISIS v10's move to native GDAL formats:is_projected(source)— projected vs geographic CRS.raster_footprint(source, *, simplify=None)— valid-data outline (built from the dataset mask, so nodata borders are excluded — the actual data shape, not the bounding box) as a shapely (Multi)Polygon in the raster CRS.footprints_to_gdf(sources, *, id_fn=None, simplify=None)— footprints of many rasters in one GeoDataFrame. Theidcolumn defaults to the file name (with extension — lossless and collision-safe across format conversions);id_fninjects a domain key (e.g. a PDS product id) sogeo.pystays instrument-agnostic. Raises on duplicate ids. Requires geopandas ([isis]extra).overlaps(gdf)— pairwise positive-area intersections between footprints.
planetarypy.isis.projected— an unfinished, unimported module that wouldNameError/AttributeErroron nearly any call (undefineddownload_pid/calibrate_pid/do_footprintinitandself.pids/calpaths/mappaths/calibs; a duplicateprocess_parallel). It was a half-generalized clone ofctx_calib.CTXCollection; its genuinely-generic ideas (footprints, overlaps, footprints→GeoDataFrame) now live ingeo.pyas the GDAL-native helpers above. Nothing imported it, so there is no user-facing breakage.
- Linting migrated from flake8 to ruff. flake8 was unenforced and its
[tool.flake8]config was dead (noflake8-pyprojectinstalled). Replaced with a working[tool.ruff](line-length 88, rules E/W/F, ignore E203/E701 — faithful parity),ruffdeclared in the[dev]extra, Makefile/CLAUDE.md updated. Removed the unused, staletox.ini(CI runs pytest directly) and its Makefile references. Fixed two dead-code findings ruff surfaced (an unused local incli.py, a duplicateimport warningsinpds/index_labels.py).
A small input-handling release: --pids-from now understands tab-separated files, not just comma CSV.
read_pids_file(the--pids-frombackend forplp fetchandplp indexes select) now accepts tab-separated input..csv/.tsv/.tabfiles route to tabular mode, stdin routes to tabular mode on a comma or a tab in the first line, and the delimiter (comma vs tab) is auto-detected from the header before parsing. Previously a tab-separated file — e.g. a spreadsheet "download as TSV" export, even one named.csv— collapsed its entire header into a single column, so--pid-key COLUMNfailed with "is not a column". Now it parses into real columns regardless of the filename. PDS product IDs contain neither commas nor tabs, so plain-text input stays reliably distinguishable. Verified end-to-end on a real 3739-row HiRISE seasonal-observations TSV export.
A plp indexes release: a new counts verb and a generic "short product ID" mechanism that expands a leading-prefix PID to all the products it matches — so a HiRISE obsid handed to the per-CCD EDR index returns every CCD product, with no instrument-specific code.
plp indexes counts KEY [COLUMN] [--columns ...] [--top N] [--dropna]— apandas.value_countsview of one or more index columns, with percent-of-total. Built for categorical columns (TARGET_NAME,MISSION_PHASE_NAME,INSTRUMENT_MODE_ID) where you want the lay of the land before filtering.--columns/-cis dual-idiom (comma-separated and/or repeated);--top 0shows every distinct value.planetarypy.pds.resolve_pids(key, pids, df, *, prefix=False)— maps each requested PID to the full PRODUCT_IDs it resolves to: exact match wins; otherwise (whenprefix=True) a leading-prefix PID expands to all matching products, sorted; otherwise empty. The generic mechanism behind the new prefix behavior — no per-instrument logic.prefix=keyword onplanetarypy.pds.get_index— routes thepids=filter throughresolve_pids, so library callers and notebooks get the same expansion.plp fetch ... --prefix— opt-in prefix expansion for downloads (e.g. a HiRISE obsid → every CCD product). Off by default to avoid surprise bulk downloads; requiresKEYto be a registered PDS index and errors clearly when it isn't or when nothing resolves.- Full
plp indexessection indocs/howto/cli.qmd(list/peek/last/counts/select/info/refresh) plus a shared "Batch PID input" section documenting the--pids-from/--pid-key/--pid-suffixfamily, and theplp fetchbatch flags that were previously undocumented.
plp indexes selectexpands short PIDs automatically. A PID with no exactPRODUCT_IDmatch that is a leading prefix of real ones now returns all matching rows, with the expansion noted on stderr ('ESP_075205_0930' → 26 products by prefix). Exact matches are never expanded. This makesplp indexes select mro.hirise.edr ESP_075205_0930(an obsid) return that observation's full set of CCD products instead of nothing.
plp indexes selectno longer dumps the schema on an empty result. A 0-row match used to render the transposed table with every field name and no values, which read like a broken result. It now leaves stdout empty (pipe-clean) and explains the empty match on stderr (0 rows / N requested, … not found).
A one-bug patch. plp spice missions and plp spice info crashed for real users on a schedule — and the test suite couldn't see it because the cache was laundering the bug.
plp spice missions/infocrashed withValueError: Unknown format code 'g' for object of type 'str'whenever the once-per-day datasets cache refreshed.get_datasets()parses the NAIF archive table withpandas.read_html, which yields every cell as a string — includingData Size (GB). The CLI formats that column withf"{...:g}", which only works on a number. The bug was invisible in normal use and in CI because the parsed table is cached to CSV, and the CSV round-trip silently coerces the column back to float; every process after a refresh read the float-typed cache and worked, while the one process that did the refresh held string-typed data and crashed. Fixed at the source:get_datasets()now coercesData Size (GB)to numeric right after parsing, so the fresh-read_htmlpath and the cached-CSV path return identical dtypes. A deterministic, offline regression test (test_fresh_parse_coerces_data_size_to_numeric) mocksread_htmlwith string cells and pins the dtype contract — it fails against the pre-fix code.
A "fix the install contract" release. Every change is shaped like a bug fix: the package declares what it actually needs, ships the docs to use it, and surfaces clear errors when something's missing. No new public-API capability — the heavy modules (ctx_calib, isis/projected) have always required geopandas + hvplot + kalasiris; this release just makes that fact visible to pip, deptry, and the user.
- Undeclared runtime deps
geopandasandhvplotare now properly declared. Both modulesctx_calib.pyandisis/projected.pydid top-levelimport geopandas as gpd/import hvplot.pandaswithout those packages being in any dependency list. Users on a freshpip install planetarypyhit aModuleNotFoundErrorthe moment they imported the CTX or ISIS submodules. The dev-env-kitchen-sink local tests couldn't see this because every dev already had those packages installed for other work. - The kalasiris try/except in ctx_calib.py and isis/projected.py was too narrow. It caught
KeyError(ISIS env vars not set) but notImportError(kalasiris itself not installed). On a fresh install without the new[isis]extra, that meant a hardModuleNotFoundErrorinstead of the warning the code was trying to emit. Widened to(KeyError, ImportError)matching the pattern inutils.py:18. utils.catch_isis_errorsilently returnedNonewhen ISIS wasn't available. The decorated function then surfaced asTypeError: 'NoneType' object is not callableto the user, with no indication that the[isis]extra was the actual missing thing. Now raises a clearImportErrorpointing atpip install "planetarypy[isis]"and the new ISIS docs page (the previous silent-None was the worst of both worlds).ctx_edr.pycrashed at import on a fresh system because it didwith CONFIGPATH.open() as fwithout checking whether~/.planetarypy_mro_ctx.tomlexisted. Mirrored the auto-create pattern used byplanetarypy.config.Config: a sane default is written on first import (PDS public URL for downloads, empty mirror/local paths that fall back to{storage_root}/mro/ctx), users edit afterwards.ssi.pyhad a stale import —from planetarypy.pds.utils import get_index(the function lives inplanetarypy.pds, notpds.utils). Broken for ages; only surfaced now because the new smoke job is the first thing that importsssiwithout a kitchen-sink env masking the issue.
- New
[isis]optional extra:kalasiris,geopandas,hvplot. Strictly a declaration of deps that the codebase has always needed for ISIS pipelines; no new capability. The extra installs the Python-side glue; ISIS itself remains a separately-managed heavy install (conda create -n isis -c usgs-astrogeology isisor the USGS docker image, with itsbin/directory on$PATH). Same shape as the existing[spice]extra. scipyadded to the existing[spice]extra.spicer.pyusesscipy.spatial.transform.Rotationvia a lazy import; the declaration was missing. Same "fix the contract" framing.richandshapelyadded to core deps. Used directly bycli.py(rich tables in many sub-commands) andgeo.Point.to_shapely()via lazy import. Both were previously relying on transitive availability (rich via typer, shapely via geopandas in dev envs); declaring them is the honest contract.docs/howto/isis_workflows.qmddocuments the two-part ISIS install (USGS binaries + the[isis]Python extra), how to make ISIS binaries reachable on$PATH, and the three distinct failure modes users may see (no[isis]extra / ISIS not on PATH / ISIS subprocess error) with a specific remediation for each.minimal-installCI job runs in parallel with the matrixed test job. Creates a fresh venv,pip install .with no extras / no dev, imports every public submodule, runsplp --helpon every sub-app. This is what would have caught every bug listed above before they ever shipped — it's the new permanent guardrail against dev-env-kitchen-sink blindspots. CLAUDE.md gains a "Testing conventions" note pointing at it.
- Lazy-imported
geopandasandhvplotin the ISIS-pipeline modules. Both moved from module-top to inside the functions that actually use them (ctx_calib.read_gml_to_gdf,ctx_calib.plot_any,isis/projected.shape_as_geoseries,isis/projected.read_gml_to_gdf,isis/projected.plot). Users on a core install can nowfrom planetarypy.instruments.mro.ctx import ctx_calibcleanly — calling the heavy functions without the[isis]extra raises a clear ImportError at the call site instead of crashing the module import. Same lazy-import pattern that already existed forscipyinspicer.py:67andpsutilinhirise.py:940. ctx_edr.pyno longer importshvplot.pandas. It was dead code — no.hvplot()call site in that file. The accessor registration happens wherever the actual caller imports.utils.catch_isis_errorre-raisesProcessErrorinstead of swallowing it. The previous catch-and-print swallowed real failures, leaving callers thinking operations had succeeded when they hadn't. Now logs (via loguru) and re-raises so callers can react.
- Stray top-level
spicer.pyat the repo root. Pre-refactor orphan from the v0.52 era; superseded bysrc/planetarypy/spice/spicer.pylong ago. Importedplanetsandtraitletsneither of which we ship. Nothing referenced it.
deptryconfiguration added topyproject.tomlunder[tool.deptry]: excludes docs/notebooks/scripts; mapspython-dateutil→dateutil; documents the legitimate per-rule ignores (planetarypy self-imports, psutil's try/except gate, lxml's call-time use by pandas.read_html, dev-tooling CLI deps, click as a transitive of typer).deptry .now reports "Success! No dependency issues found" cleanly — a static-analysis companion to the runtime smoke job.
A maintenance release: stale dependencies removed, one misclassified dependency moved into core where it belongs, and two pieces of documentation added for collaborators (AI or otherwise) working in the repo.
matplotlibpromoted from[spice]extra to core dependencies.planetarypy.plotting(imshow_gray,add_sun_indicator,imshow_with_sun) and theplpvisualize verbs all import matplotlib unconditionally; none of that is SPICE-related. With matplotlib in[spice], anyone runningpip install planetarypywithout the extra and thenfrom planetarypy.plotting import imshow_graysaw a confusing ImportError that mentioned matplotlib but not anything SPICE-related. The fix makes the install correct for that workflow.
fastcorefrom core dependencies. Only use in the codebase wasfrom fastcore.utils import Pathinsrc/planetarypy/pds/index_labels.py; replaced withfrom pathlib import Path(semantically identical for our usage — all call sites pass strings toPath(...)).planetsfrom the[spice]extra. Zero imports anywhere;planetarypy.constants(PCK + JPL DE440 + NSSDC composed inconstants/__init__.py, added in v0.61.0 and overhauled in v0.64.0) provides everythingplanetswas previously used for.
(Note: lxml was briefly removed in a pre-tag iteration of this release, but the tag-push CI caught that it's required at runtime by pandas.read_html() in spice/archived_kernels.py:180 — kept in the dependency list with a comment documenting the transitive use.)
CLAUDE.mdat the repo root, promoted from a gitignored personal-doc to a committed shared resource. Comprehensive working agreement for AI coding agents (Claude Code, Cursor with Claude, Copilot Chat) operating in this repo: project map, code/CLI/testing conventions, development principles (surgical fixes, semver rule, partial-answers-aren't-agreement, state-machine bug review heuristics, etc.), full release process. The deliberately-excluded category is personal interaction infrastructure (output formatting headers, memory writing protocols) — those stay in maintainers' personal~/.claude/CLAUDE.md.docs/howto/planetaryimage_today.qmd— modern replacements for the (no-longer-maintained)planetaryimagepackage's functions. Direct translation table forPDS3Image.open/CubeFile.open/img.data/plt.imshow(..., cmap='gray')to the rasterio +planetarypy.plottingequivalents. Includes the modernized version of a widely-shared 2015 demo notebook and a section on whenpdris the better choice over rasterio. Auto-discovered by the existingdocs/howto/index.qmdlisting block.
- 798 tests pass serially under the cleaned-up dep set. Verified the
fastcore→pathlibswap is invisible via focused test runs ontests/test_pds_static_index.py,tests/test_pds_pids_filter.py, andtests/test_cli_indexes.py.
Column projection across the plp indexes family, a dual-idiom (repeated-flag-or-comma) treatment for both --columns and --ccds, a more visible freshness state on plp indexes info, plus a real bug that had been silently telling users their HiRISE indexes were up to date when they weren't.
plp indexes peek/last/select --columns/-c COL[,COL...]— project the displayed rows to the named columns, in the order given. Comma-separated, repeated-flag, or any mix all work:Unknown column names produce a clear error listing every available column. Two ordering subtleties handled:plp indexes last mro.ctx.edr --columns "PRODUCT_ID,IMAGE_TIME" --rows 2 --sort plp indexes peek mro.ctx.edr -c PRODUCT_ID -c IMAGE_TIME plp indexes select mro.ctx.edr P_A -c PRODUCT_ID -c "IMAGE_TIME,EMISSION_ANGLE"
plp indexes last --sortruns sort before projection so the time column can drive the sort even if you project it away;plp indexes selectcomputesmissing_pidsbefore projection so the PID column survives for the diff.planetarypy.pds.get_index(..., columns=None)— keyword-only parameter mirroring the CLI flag. Exact (case-sensitive) match; unknown names raiseKeyErrorlisting every available column. API-first per the project's CLI-thin / lib-fat discipline.plp hiedr --ccdsandplp himos --ccdsaccept the same dual idiom as--columns:--ccds 4,5,--ccds 4 --ccds 5, or any mix. Non-integer tokens raise a cleanBadParameterinstead of crashing inside the CCD loop. New helper_parse_ccdsmirrors_parse_columns; both old call sites now share it instead of inlining[int(n) for n in ccds.split(",")] if ccds else None.update available?row onplp indexes info— completes the freshness picture alongside the existinglast updatedandlast checkedrows. Rendersyes — run \plp indexes refresh --cache KEY`orno(or(check failed: ...)` if the remote HEAD couldn't be reached).
StaticRemoteHandler.update_availablesilently returning False on stale-but-cached state. Real-world report:plp indexes info mro.hirise.edrclaimed no update was available when the user had manually confirmed a newer index existed. Root cause:Index(...)instantiation callsget_remote_timestamp(), which writes bothremote_timestampand (via the bundledlog_remote_checkside-effect)last_checkedto "now". That silencesshould_checkfor the next 24h. The next read ofupdate_availablethen short-circuited onnot self.should_checkand never ran theremote_time > last_updatecomparison — leaving the flag stuck at whatever was previously logged (typically False after a long-past download). The fix removes theshould_checkgate from the comparison path:should_checknow only gates whether to fetch a fresh remote timestamp; the comparison runs whenever aremote_timestampis available (cached or fresh). Regression test pins the exact scenario (cached remote-ts > last_update + should_check False + flag unset → must be True). After upgrading, the very next invocation of anyplp indexesverb correctly flips the flag for affected indexes.
AccessLog.log_remote_timestamprenamed tolog_remote_checkwith a clarified docstring that spells out the two writes performed atomically (remote_timestampfrom the server'sLast-Modified, pluslast_checkedfrom our wall-clock). The previous name suggested a single-field setter, which was what made the bug above so hard to spot — readers two layers up couldn't see that calling it also bumpedlast_checked. Behavior unchanged; single internal caller updated.
- 17 new tests across the touched layers:
test_pds_pids_filter.py(columnsprojection API: 4 tests),test_cli_indexes.py(peek/last--columns+ dual idiom + projection-after-sort: 5 tests),test_cli_indexes_select.py(select--columnsacross table/csv/jsonl formats + missing-PIDs-diff-survives-projection: 5 tests),test_cli_parse_ccds.py(the new helper, all forms + non-int error: 9 tests),test_pds_static_index.py(the regression for the silent-staleness bug: 1 test),test_pds_index_logging.py(renamedtest_log_remote_check).
A small UX improvement for plp indexes info: the local-cache row finally gets two siblings reporting when that cache was last downloaded and when we last asked upstream about updates. The data was already tracked on every index's AccessLog; this release just surfaces it.
plp indexes info KEYgains two new rows in the Rich-rendered table:Each renders with a compact relative-age suffix (last updated 2026-02-23 11:01:28 (99d ago) last checked 2026-06-02 22:08:37 (just now)just now/30m ago/5h ago/99d ago) alongside the absolute timestamp, or(never)when the access log carries no datetime yet (fresh installs, or indexes you haven't touched). At a glance you can now tell whether your cached parquet is stale and whether the once-per-day update check has fired recently.
- New
_format_when(d)helper incli.py: renders a datetime with a relative-age suffix; handles tz-aware/naive mismatches so it's robust to whatever the log writer produces. 2 new regression tests inTestIndexesInfoFreshness(real-datetime → relative-age strings;None→(never)) — both stubIndex+AccessLogto stay fast and offline.
CSV inputs and a better failure report for the batch-PID workflow. Real-world driver: feeding a HiRISE observation-CSV through head | plp fetch to grab the first few RED products — which surfaced two design oversights (stdin always parsed as plain text; FAIL block was an unreadable wall of text) plus the discovery that the suffix idiom belongs in the API too.
-
planetarypy.pds.read_pids_file(source, *, index_key=None, pid_key=None, suffix=None)— single entry point for "read PIDs from a file or stdin", with smart format dispatch:pid_keyset → CSV (the explicit "this is tabular" signal).- File with
.csvextension → CSV. - Stdin whose first non-blank line contains a comma → CSV (small heuristic so
head file.csv | plp fetch ...Just Works). - Otherwise → plain text via
planetarypy.utils.read_pids.
In CSV mode the PID column is resolved by
pid_key(explicit) orpid_column(index_key, df)(auto-detect via the catalog registry). Failure raisesValueErrorlisting the CSV's columns so the caller knows what to pass topid_key. The optionalsuffixis appended to every returned PID; empty string is a no-op. Designed API-first so notebooks and scripts can ingest CSVs without going through the CLI. -
plp fetch --pid-key NAME/plp indexes select --pid-key NAME— name the CSV column to read PIDs from when auto-detection can't (or shouldn't) pick. Also forces CSV parsing on stdin / non-csv-extension paths, sohead file.csv | plp fetch KEY --pids-from - --pid-key NAMEworks. -
plp fetch --pid-suffix STR/plp indexes select --pid-suffix STR— append a fixed string to every PID read from--pids-from. Motivated by HiRISE-style files that carry observation IDs (PSP_xxxxx_yyyy) when the downstream call needs a more specific product (PSP_xxxxx_yyyy_RED). Intentionally scoped to file/stdin input only; positional PIDs are passed through verbatim because hand-typed PIDs already carry their full identifier. -
docs/howto/hirise_rdr_red_batch.md— cheat sheet walking through the four ways to grab HiRISE RDR_REDproducts: single positional, variadic positional, full CSV file (with--pid-key), andhead-pipe via stdin. Includes a flag-summary table for the four scenarios.
- Batch FAIL output is now multi-line and scannable. The previous one-line-per-failure format (
FAIL PID: ErrorType: msg) became a wall of unreadable text when error messages were long. New shape:PIDs over 60 chars get middle-ellipsis truncation (FAIL ESP_089803_2650_RED └ ProductNotFoundError: Product 'ESP_089803_2650_RED' not found for mro.hirise.rdr. Check the product_id spelling. FAIL …X…Y) so pathological cases (whole CSV rows that became pseudo-PIDs from a misconfigured input) don't overflow the terminal. Error message is indented under a└marker, soft-wrapped to terminal width (capped at 100). Blank line between entries. Report is padded with leading and trailing blank lines so it stays visually quarantined fromtqdm's progress bar (which leaves the cursor mid-line and would otherwise collide with the first FAIL).OKlines in--report fullstay one line each.jsonlandcsvmodes are unchanged (their consumers don't need formatting).
- 23 new tests across
tests/test_pds_read_pids_file.py(coveringread_pids_filedispatch, CSV auto-detection, explicitpid_key, suffix application, stdin sniff, plain-text fallback) andtests/test_cli_batch_pids.py(covering CLI--pid-key,--pid-suffix, new FAIL block formatting, long-PID truncation, blank-line separation). Two pre-existing tests updated for the new format strings. - CI hygiene:
tests/test_ctx_calib_shim.pyadoptspytest.importorskip("hvplot")so collection survives in the minimal pip env;test_generic_kernel_urls_are_accessibleintest_kernels.pymarked@pytest.mark.slowso flaky NAIF HEAD requests stop blocking every-push CI (the test still runs locally; a scheduled-workflow home is left as a separate follow-up).
Batch-PID cycle: API-first capability for "do this thing for a list of PIDs", surfaced as two new CLI verbs and a parallel batch helper that other commands can plug into. Plus a small UX consistency pass across the plp indexes sub-app and a duplicate-code cleanup in instruments.mro.ctx.
planetarypy.utils.parallel_map(func, items, *, workers=4, executor='thread'|'process', desc=None)— canonical helper that returnslist[(item, result, exception)], preserving input order and continuing past per-item failures. Promotes theprocess_parallelpattern previously duplicated ininstruments/mro/ctx/ctx_calib.pyandisis/projected.pyinto a single shared utility; new code should reach for this directly instead of standing up its ownThreadPoolExecutor/as_completedplumbing.planetarypy.utils.read_pids(source)— read PIDs from a file (or'-'for stdin). One per line; blank lines and#-prefixed comments stripped. No deduplication — the caller decides whether duplicates matter. Designed to feed any of the newpids=/product_ids=APIs below.planetarypy.pds.get_index(..., pids=None)— whenpidsis given, returns the index DataFrame filtered to only those PIDs. Column resolution lives in the new publicplanetarypy.pds.pid_column(index_key, df)helper, which honorsIndexConfig.product_id_colthen falls back through(PRODUCT_ID, FILE_NAME, IMAGE_ID, OBSERVATION_ID).planetarypy.pds.missing_pids(df, index_key, pids)— order-preserving diff helper: returns the input PIDs not present in the index's product-id column. Pairs naturally withget_index(pids=...)to surface "which IDs did the index not know about?" in batch workflows.planetarypy.catalog.fetch_products(key, product_ids, *, workers=4, ...)— parallel batch wrapper aroundfetch_product, usingparallel_mapunder the hood. Returnslist[BatchFetchResult](also new) with.product_id/.downloaded/.exception/.okfields per PID. Callshave_internet()upfront and raises the newOfflineErroron preflight failure;skip_online_check=Truebypasses for offline mirrors or captive networks.plp fetch KEY [PIDS...]— variadic positional PIDs. Single-PID call preserves the existing output contract (URL to stderr, files to stdout) so shell composition (cd (plp fetch --folder ...),qgis (plp fetch ...)) keeps working bit-for-bit. Multi-PID calls go throughfetch_productswith--workers N(default 4), continue-on-error processing, and a--report errors-only|full|jsonl|csv(defaulterrors-only) for the per-PID outcome view. Exit 0 if all OK, 1 if any failed;jsonl/csvmodes always exit 0 since downstream consumers should parse the per-rowokflag.--folderis rejected in batch mode (only makes sense forcdcomposition).plp fetch --pids-from PATH— read PIDs from a file (or-for stdin). Mutually exclusive with the variadic positionals.plp indexes select KEY [PIDS...] [--pids-from PATH]— new verb dedicated to "filter to these specific PIDs". Kept separate frompeek(random sample) andlast(trailing rows) because the premises differ.--format auto|table|csv|jsonldefaults toauto: transposed Rich table when matched rows are at most--max-table-rows(default 3), otherwise CSV — small N for visual inspection, large N for piping.--report errors-only|fullcontrols how missing PIDs are surfaced on stderr (stdout stays clean for the pipe consumer regardless of--format).- New main-config knob
max_table_rows(default3) in~/.planetarypy_config.toml. Threshold above which row-display commands switch from the transposed Rich table to CSV. Fresh configs ship with the key + an explanatory comment; legacy configs get it backfilled on next read. The CLI flag--max-table-rows Noverrides it per call.
- Help-on-missing UX across the entire
plp indexessub-app. Bare invocations ofplp indexes peek,plp indexes last,plp indexes info,plp indexes refresh, andplp indexes selectnow print the command's help text and exit 0, instead of emitting Typer's auto "Missing argument 'KEY'" error. The pattern matches the existingplp fetchUX — calling a verb with no args should teach you what it does, not bark at you. (plp indexes refreshhad no positional arg; its analogous case "neither--confignor--cachegiven" now also shows help.) planetarypy.instruments.mro.ctx.ctx_calib.process_parallelis now a thin backward-compatible shim overplanetarypy.utils.parallel_map. Public signature (Executor class + task + pids + refresh kwarg) and the original early-failure semantics (raise the first exception) are preserved; the inlineThreadPoolExecutor/as_completed/tqdmorchestration is gone. The matching duplicate inplanetarypy.isis.projectedis not touched in this cycle — separate cleanup.docs/howto/cli.qmdgains a "Design philosophy" paragraph stating the API-first / CLI-wraps-thin discipline explicitly: everyplpverb is a thin wrapper over a public Python API; useful logic lives inplanetarypy.*modules, not incli.py. Notebooks and downstream tooling should pick up new capabilities without screen-scraping or shelling out.
- 73 new tests across 7 files:
test_utils_parallel.py(8),test_utils_read_pids.py(10),test_pds_pids_filter.py(14),test_catalog_fetch_products.py(8),test_cli_batch_pids.py(14),test_cli_indexes_select.py(15),test_ctx_calib_shim.py(4); plus 4 new tests added totest_cli_indexes.py(help-on-missing for peek/last/info/refresh) and 3 new config-layer tests intest_config.py(fresh-config presence, legacy backfill, explicit value preservation formax_table_rows). One process-pool test is skipped under pytest-xdist (nested process pools don't survive the worker fork/spawn).
A bugfix release that finally delivers the v0.64.0 NSSDC parser improvements to end users on PyPI/conda. The parsed fact-sheet archive isn't bundled in the wheel (it's lazy-downloaded from Zenodo on first use), and the local cache filename in planetarypy.constants.nssdc._loader was keyed only on the JSON schema version — which v0.64.0 didn't bump, because the schema didn't break. Result: anyone with a pre-v0.64.0 cache kept reading the old buggy data, and fresh installs pulled the original v1.0.0 Zenodo deposit which was also pre-fix. This release uploads the corrected archive as Zenodo deposit v1.1.0 (concept DOI 10.5281/zenodo.20122986) and refactors the loader's cache key so future data refreshes propagate automatically to existing users.
- NSSDC parser fixes from v0.64.0 now reach end users. The local cache filename is now
parsed_archive_v{EXPECTED_ARCHIVE_VERSION}_z{ZENODO_RECORD_ID}.json.gz— keyed on both the JSON schema version and the per-version Zenodo record ID. BumpingZENODO_RECORD_IDin a release invalidates existing caches and triggers a redownload, which is how data-only updates actually propagate. Previously onlyEXPECTED_ARCHIVE_VERSIONwas in the filename, so pure data refreshes (no schema change) were invisible to the cache layer. ZENODO_RECORD_IDbumped20122987→20426712. Points at the new Zenodo deposit v1.1.0, which contains the parsed archive with all v0.64.0 parser fixes baked in (J2 oblateness restored on 9 planets, range/uncertainty plumbed, unit aliases forhours/g/mole/degrees,±and10^Nextraction, Title-CaseSurface Gravity, NSSDC label-wording drift, unit-paren detector no longer eating non-unit qualifiers).
- Versioning policy in
_loader.pydocumented and split. Two independent knobs, explicitly commented:EXPECTED_ARCHIVE_VERSION(JSON schema — bump only on breaking shape changes; additive optional keys like the v0.64uncertainty/rangekeys don't count) andZENODO_RECORD_ID(data revision — bump every Zenodo upload). The previous single-knob "bump only on breaking schema changes" policy was technically defensible but incompatible with delivering data updates: without a freshness signal, no breaking schema → no cache miss → no propagation. New split makes that contract explicit. - Zenodo bundle script (
scripts/build_nssdc_zenodo_bundle.py) plumbing. NewDEPOSIT_VERSION = "1.1.0"andCONCEPT_DOI = "10.5281/zenodo.20122986"constants. The generatedREADME.mdinside the bundle now cites the deposit version (in title and citation block), surfaces the JSON schema version separately in the schema reference section, and uses the concept DOI in the citation rather than a version-pinned record ID. Bundle output directory becomesbuild/nssdc_archive_v{DEPOSIT_VERSION}/(was schema-version-named).
End-user constant values will change after this upgrade — the same data changes documented under v0.64.0's "Fixed" section now actually take effect (J2 populated on 9 planets, range/uncertainty plumbed through, ~1400 readings recovered from the unit-paren-eater fix, etc.). Strict semver permits this under PATCH because the pre-fix values were buggy, but pinned-equality tests against the old constants will see diffs.
Two CLI quality-of-life additions for plp indexes, plus a small main-config knob to silence upstream deprecation noise that one of them provokes.
plp indexes last KEY [-n N] [--sort]— show the trailing rows of a PDS index, transposed in the same layout asplp indexes peek(one output column per shown index row, field names down the left). Default 3 rows;--rows/-nadjusts.- Default order: file order. Most PDS indexes are appended chronologically as new products land, so the last row in the parquet IS the newest entry. Cheapest read — no sort.
--sort/-s: auto-detect a canonical time column (START_TIME/OBSERVATION_TIME/IMAGE_TIME/TIME, in order) and sort ascending before taking the tail. Useful when the parquet isn't actually chronologically ordered. Falls back gracefully with a stderr notice if no canonical time column is present.- Internal: extracted
_render_index_rows()sopeekandlastshare the table-rendering code;peek's behavior is unchanged.
- New main-config knob
filter_deprecation_warnings(defaulttrue) in~/.planetarypy_config.toml. SuppressesDeprecationWarningduringplpexecution so end users don't see upstream noise (notably Typer'sshell_complete=-is-deprecated notice that the segment-aware completion below provokes). Devs working on planetarypy can set this tofalseto see the warnings as reminders. Standard Python-Wflags andPYTHONWARNINGSenv var still stack with this filter — it doesn't override an explicit user policy. The key is written into freshly-created configs with an explanatory comment block; legacy configs (existing users who installed before this version) get it backfilled on next read so they too see the knob exists.
-
plp indexestab completion is now segment-aware. Pressing<TAB>after just an instrument or indexname segment —ctx<TAB>,hirise<TAB>,CTX<TAB>— now completes to the full dotted key (mro.ctx.edr,mro.hirise.edr, …). No more "did I have to remember the mission prefix?" friction. Whole-key prefix still wins (mro.<TAB>keeps working the obvious way); the segment-prefix branch only fires when whole-key prefix produces nothing. Case-insensitive. Applies to every command that resolves an index key (peek,last,info,refresh,example_pid,meta).Implementation: wired via Click's native
shell_complete=rather than Typer'sautocompletion=because Typer's wrapper post-filters returned candidates withvalue.startswith(incomplete)— which would have silently dropped every segment-prefix match. This is the reason for the newfilter_deprecation_warningsknob; Typer emits aDeprecationWarningaboutshell_complete=going away in a future version. If/when that lands, the fix is either to revert to prefix-only completion underautocompletion=, or upstream a filter opt-out flag.
- 18 new tests across
tests/test_cli_indexes.py(15) andtests/test_config.py(3): completion paths (whole-key precedence, segment-prefix fallback, case-insensitivity, adapter wiring),lastcommand behavior (default 3-row tail,-nlimit,--sortpicks time column,--sortfalls back, peek regression guard), warning-filter behavior (default-on, explicit-true, explicit-false), config backfill (fresh has key, legacy gets backfilled, explicit-false survives backfill).get_indexis patched so the suite stays offline.
A planetarypy.constants cycle focused on (1) restoring the design contract that the iauNNNN modules contain only IAU data, (2) finding and fixing a cluster of "silently dropped NSSDC values" bugs that had been hiding in plain sight in the 30-year fact-sheet archive, and (3) plumbing through structured error information (measurement uncertainty, naturally-varying ranges) that the parser was previously throwing away.
35 new constants become available across the 11 NSSDC bodies at the latest-capture view, plus thousands more recovered in the time-indexed nssdc.history(...) API across the full archive. Public API stays backwards-compatible: from planetarypy.constants import Mars still resolves the same Body with the same fields.
Range(min, max)dataclass withmidpointandhalf_widthproperties (planetarypy.constants.Range). Represents bounds for quantities NSSDC publishes as "X - Y" (seasonal/diurnal/spatial variation). Kept distinct from measurement uncertainty by design — see the explanation doc for the rationale.Constant.uncertainty: floatandConstant.range: Optional[Range]— two orthogonal optional fields on everyConstant. Default to absent (0.0/None) so every existing call site keeps working unchanged.__repr__shows± Xinline and(range X–Y)after the value, or[range X–Y]in place of the value when value is NaN.RangeWarning(UserWarning)— issued once per process when aConstantwith a populated.rangereturns the midpoint as its scalar value (only when the user has explicitly opted into the midpoint strategy via config). Standardwarnings.filterwarnings(...)works; the config switch below silences it permanently.- Two new config keys under
[constants]in~/.planetarypy_config.toml:range_strategy = "nan" | "midpoint"(default"nan") — for range-typed entries, controls whetherConstant.valueisNaN(faithful, the default —NaNpropagates through arithmetic as a visible signal) or the range midpoint (ergonomic, opt-in, emitsRangeWarning).warn_on_range_midpoint = true | false(defaulttrue) — suppress theRangeWarningeven when in midpoint mode (for users who've made the trade and want quiet output).
tests/test_constants_vs_sources.py(21 tests) — three-layer anti-hallucination suite: (1) PCK-sourced Constants round-trip againstspiceypybit-for-bit (both 2009/2015 editions) with reverse-coverage on the kernel pool; (2) NSSDC parser coverage ratchet over the 913 in-repo HTML captures with an allowlist of expected drops, so any new silent drop fails CI; (3) 16 hand-verified(body, field, expected_value)triples against the live NSSDC fact-sheet pages.tests/test_constants_nssdc_parser.py(17 tests) — focused parser unit tests on small synthetic HTML inputs: scientific notation (10^N/X x 10^N/X × 10^N), qualifier prefixes (~/</>/≈and stacked combos),+/-and±uncertainty, range extraction with/without units, newline-doesn't-bleed-into-unit regression.tests/test_constants_nssdc_loader.py(12 tests) — loader unit-coercion surface: known-unit lookup, scaled-unit factor,x-prefix normalization, per-field default-unit fallback, precedence rules.tests/test_constants_range_strategy.py(10 tests) — runtime strategy plumbing: both NaN and midpoint strategies, once-per-process warning gate, config-driven suppression, uncertainty propagation, NaN-aware repr.
constants.iau2009andconstants.iau2015are now PCK-only. Previously each generated module inlined IAU PCK polynomial fields, JPL DE440 GMs, and NSSDC fact-sheet values under a namespace named after just one of those sources — a contract violation. Now each source lives in its own module and is composed at import time inconstants/__init__.py(PCK + JPL GM + NSSDC). The generatediauNNNN.pyliterals collapse from 2706 lines to ~12 lines each. Public API unchanged:from planetarypy.constants import Marsstill resolves the same Body with the same fields, but each Constant now correctly reports the source it actually came from (PCK / JPL DE440 / NSSDC) rather than inheriting the iau module's PCK label.- New
constants._gm_jplmodule, generated fromgm_de440.tpcalone. Single source of truth for GMs, composed onto every Body regardless of which IAU edition's PCK supplies the cartographic fields. GMs aren't IAU-edition-versioned — they track JPL DE-series ephemeris releases — and the new structure makes that explicit. - NSSDC parser now captures range and uncertainty structure instead of silently dropping the rows or extracting garbage as the unit.
X +/- Y unityields{value, uncertainty, unit};X - Y unityields{range: {min, max}, unit}withvalueomitted (interpretation-free). The runtime loader applies the user'srange_strategypreference to decide how to surface the value. Atmosphere fallback regex also gains scientific-notation value matching (10^-15,X x 10^N,X × 10^N) and qualifier-prefix tolerance (~,<,>,≈). - Per-field default unit table in the loader (
_FIELD_DEFAULT_UNITS). NSSDC publishes a few fields with no unit because the discipline-conventional unit is implicit (Earth'sMean molecular weight: 28.97). Without a fallback, the same field loaded asdimensionlessfor some bodies andg/molfor others — a typed inconsistency. Current entry:mean_molecular_weight → u.g/u.mol. Auditable per-entry; easy to extend. docs/factsheets.qmdregenerated. New rows for J2, Flattening, V-band magnitude, Moment of inertia, Pluto surface gravity, Mercury surface pressure across the planets that publish them. Mars surface density now reads0.016 ± 0.006 kg/m³(uncertainty); Neptune scale height reads19.1–20.3 km(range). Unit display also fixed where the pre-fix archive had truncated forms (Venus surface densitykg/m→kg/m³).docs/explanation/constants_design.qmdgains a "NSSDC error-info: uncertainty vs range" section documenting the two concepts, their distinct semantics, therange_strategyconfig knob, and how to suppressRangeWarning.
NSSDC parser fixes in this cycle. Each is source-only (didn't touch the in-repo parsed_archive.json.gz until the final regen commit), so the test allowlist tightens with each.
J2(oblateness coefficient) on all 9 NSSDC planets — Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, Neptune, Pluto. Two-stage bug: (a)<sub>2</sub>was rewritten to the marker form_2(intentional, so unit strings likem/s^2survive tag stripping), but FIELD_MAP only had the key"J2", so 687 readings across 30 years silently disappeared; (b) the regenerator's unit table had no entry for NSSDC's"x 10^-6"notation, so even after the parser fix the value was still dropped during unit coercion. Both gaps closed; loader-side_UNIT_MAPkept in sync.- Unit-paren detector no longer eats non-unit qualifiers.
_split_label_unit()greedily stripped any trailing(...)as a unit, truncating labels like"Visual magnitude V(1,0)"(where(1,0)is photometric notation, not a unit) and"Ellipticity (Flattening)"(where(Flattening)is a synonym qualifier, not a unit). FIELD_MAP entries for all three labels existed but were unreachable. Fix short-circuits the splitter when the full string is already a known FIELD_MAP key. Recovers ~1400 readings across the archive — 583 Visual magnitude, 227 V-band magnitude, 629 Flattening across nearly every NSSDC planet + Moon. - NSSDC label wording drift — older captures used longer-form labels (
"Orbital inclination","Orbital eccentricity","Inclination to equator") that had no FIELD_MAP entry, only their shorter siblings ("Orbit ...","Inclination of equator"). Aliased each to the same target attr. Recovers 103 readings. - Title-Case
Surface Gravityvariants aliased tosurface_gravity. NSSDC alternated the casing across captures and only the lowercase form had an entry. Recovers 14 readings. - Three new NSSDC unit aliases in both
_NSSDC_UNIT_EMIT(regenerator) and_UNIT_MAP(loader):"hours"→u.hour(Jupiter/Neptune/Saturn/Uranus sidereal rotation period, 202 readings),"g/mole"→u.g/u.mol(mean molecular weight on 6 bodies, 123 readings),"degrees"→u.deg(4 outer-planet orbit inclinations). <sup>/<sub>markers re-attached to their preceding word. Modern NSSDC captures wrap<sup>in<span>, so after tag strippingkg/m<span><sup>3</sup></span>becamekg/m ^3(exponent visibly disconnected). The atmosphere-fallback regex then dropped the^3and the row coerced askg/m(no entry in the unit table). Collapsed the spacing artifact. Recovers Mars and Venussurface_density(64 readings) plus pre-empts ROW_SPLIT_RE misidentifying values containing exponents as labels.- NSSDC atmosphere fallback no longer eats newlines. The
\s*between value and unit-capture was greedy and consumed line breaks, allowing the next section header ("Atmospheric composition: ...") to bleed in as a fake unit. Switched to[ \t]*(horizontal whitespace only). Recovers 278 readings ofmean_molecular_weightacross 7 bodies that hadunit = "Atmospheric"baked in. - NSSDC uncertainty and range entries are now real values instead of being dropped or mislabeled.
X +/- Y unitbecomes a Constant with uncertainty;X - Y unitbecomes a Constant with aRange(and, depending onrange_strategy, eitherNaNor the midpoint as the scalar value). Examples baked into the regenerated archive: Marssurface_density = 0.016 ± 0.006 kg/m³, Neptunescale_height = range [19.1, 20.3] km, Neptunemean_molecular_weight = range [2.53, 2.69], Plutomean_molecular_weight = range [16, 25] g/mole. - Scientific-notation values in the atmosphere fallback (
10^NandX x 10^N). Mercury'sSurface pressure: ~10^-15 barnow correctly loads as1e-15 barinstead of dropping the row (the regex previously matched only the10, leaving^-15as junk for unit coercion). Qualifier prefix widened to[<>~≈]*so stacks like<~5 x 10^-15parse cleanly too. - Per-field default unit fallback unifies Earth's
Mean molecular weight: 28.97(unit-less in the source) with other bodies'g/moleentries. Previously the same field loaded asdimensionlesson Earth andg/molelsewhere.
- Anti-hallucination test suite added for
planetarypy.constants(see Added section). 60+ new tests across four files cover the parser, loader, runtime strategy, and end-to-end archive coverage. scripts/parse_nssdc_archive.pygains aSkippedReportdataclass + optional_report=kwarg threaded through_parse_pre_block,_parse_table,parse_capture. Default behavior unchanged; the kwarg is only populated by the parser-coverage test that ensures no NSSDC content slips past the parser unnoticed.scripts/regenerate_constants.pyrewritten to emit the three source modules (_gm_jpl.py,iau2009.py,iau2015.py) independently. NSSDC merging removed from the iau-module pipeline.
-
plp spiceCLI sub-app — five verbs covering kernel discovery + fetch. Mirrors the existingplp catalog/plp indexesshape so SPICE work no longer requires breaking out to Python for the easy parts:plp spice missions— Rich table of all 39 NAIF mission archives (shorthand, full mission name, date range, cumulative bundle size). Reads fromarchived_kernels.datasets.plp spice info <mission>— date range + archive metadata + URLs (PDS readme, archive root, NAIF subsetter URL) for one mission. Misspellings get difflib suggestions ("cassiny" → "did you mean: cassini?"); tab completion on the mission shorthand.plp spice fetch <mission> --start --stop [--save-location]— date-scoped kernel subset download. Wrapsarchived_kernels.get_metakernel_and_files(); metakernel path goes to stdout so shell composition Just Works (spiceinit mkpre=$(plp spice fetch cassini --start … --stop …)).plp spice cached [--total]— Rich table of every kernel currently cached under{storage_root}/spice_kernels/, grouped by mission with file count, on-disk size, and a sample of filenames per group. New backing helperarchived_kernels.list_cached_kernels() -> dict[str, list[Path]](public in__all__).plp spice generic <name> [--force]— fetch one generic kernel by short alias. New backing helpergeneric_kernels.download_generic_kernel(name, overwrite=False) -> Path(public). Aliases:lsk,pck,masses,de430,mar099s. Full path-fragments accepted for non-default kernels (e.g.lsk/naif0011.tls). Cached-aware: returns the local Path immediately if already on disk. Tab completion across the alias set; newGENERIC_KERNEL_ALIASESdict exposed alongside.
Per the thin-wrapper rule, both new helpers live as proper Python APIs in
planetarypy.spice.*and are tested independently of the CLI; the CLI commands are short adapters over them.
plp --helpoutput is now grouped into 6 panels instead of one flat 13-verb list. Categories: Discovery & browsing (catalog, indexes, spice, constants), Fetch & download (fetch, hibrowse, hiedr, himos), Inspect a product (meta, example_pid), Visualize (ctxqv), Science computations (spicer), Maintenance (ctx-migrate). Implementation:rich_help_panel="..."on each@app.command()decorator and on eachapp.add_typer(...)registration. Panel rendering order is determined internally by typer/rich-click (not by source order or alphabetical) — the grouping itself is the readability win; tweaking the order requires a rich-click config override that isn't worth the complexity here.
- Body.iter_constants() extracted from
cli.pyintoplanetarypy.constants.base.Bodyso theplp constantstable-render and tab-completion paths stop duplicating the "iterate dataclass fields, filter to Constants" loop. Yields(field_name, Constant)for every Constant-bearing field; skipsNonefields and non-Constant values (metadata likebody_class/naif_id, polynomial-coefficient tuples, scalar floats). Useful for introspection beyond the CLI; 3 unit tests intests/test_constants_base.pypin the contract. - Misc simplifications in
cli.py: dropped over-defensivetry/exceptaround stable internal imports in_complete_constants_query; droppedisinstance(b, Body)filters (BodyRegistry only contains Bodies by construction); extracted_body_name_set()and_suggest_and_exit()helpers shared between completion paths and error suggestions; direct.sourceattribute access on Constants instead ofgetattr(...) or ""defensive chains. Plus a Lua-filter cleanup indocs/_abbreviations.lua(removed unusedsuffixcapture, redundant rename, recomputedafter, etc.).
Note: a 0.61.1 release was published to PyPI between 0.61.0 and 0.62.0 with this same content. It was bumped as a patch by mistake; the new- CLI-subcommand addition is backwards-compatible-additive, which is textbook minor-version territory. 0.61.1 stays on PyPI for posterity; 0.62.0 is the version to install. Bytes are identical apart from the version string.
Body.iter_constants()— generator yielding(field_name, Constant)for every Constant-bearing field on a body. SkipsNonefields and non-Constantvalues (metadata likebody_class/naif_id/dwarf_planet, polynomial-coefficient tuples, scalar floats likeflattening). Useful for tabular display, introspection, and CLI completions without callers needing to know the dataclass schema. Both theplp constantstable-rendering and tab-completion paths now route through this method instead of duplicating the filter-by-isinstance loop — keeps the CLI a thin wrapper.plp constantsCLI subcommand. Two forms:plp constants Mars— Rich-rendered table of every scalarConstantattached to the body, with a source column (PCK kernel filename or NSSDC capture stamp per field). Filters out non-Constant metadata likebody_class/dwarf_planet/naif_idso the table only contains actual quantities.plp constants Mars.GM— value on stdout, provenance lines (# source: pck00011.tpc,# reference: IAU 2015 — Archinal et al.…) on stderr, soplp constants Mars.GM | awk '{print $1}'Just Works. Body matching is case-insensitive (mars==Mars==MARS). Misspelt bodies and unknown fields each exit non-zero withdifflib-driven close-match suggestions on stderr (e.g.'jupier'→ "did you mean: Jupiter, Juliet?";Mars.gravity→ "did you mean: surface_gravity?"). Carries the time-travel facility through to the CLI via--at/-t:plp constants Mars.pole_dec --at 2012returns 52.886° (sourced frompck00010.tpc/IAU 2009), demonstrating the PCK-edition swap from a shell. Tab completion offers body names before the dot and Constant-bearing field names after it (gas-giantJupiter.surface_<TAB>correctly returns nothing since those fields are unset). 13 new tests intests/test_cli_constants.pypin the contract.
- Bare invocation now prints
--helpfor every top-level CLI command. Previously,plp catalog/plp indexes(sub-app groups) showed full help on bare invocation (typer's freeno_args_is_help=Truefor groups), but the eight individual commands with required positionals —fetch,hibrowse,hiedr,himos,ctxqv,spicer,example_pid,meta— instead emitted typer's terseMissing argument 'KEY'.error (exit 2). Now every top-level command exits 0 with the full help block when invoked without arguments. Two-positional commands (fetch,meta) still error on partial invocation with a clearError: missing PRODUCT_ID argument.message and exit 2, so the "give me a hint, I have the first arg" flow is preserved. Pattern: addedctx: typer.Contextparameter, defaulted the first positional toNone, and inserted anif x is None: typer.echo(ctx.get_help()); raise typer.Exit()block at the top of each function body. Cheap and consistent.
planetarypy.constants— a one-stop constants subsystem. Three layers in a single namespace:- Fundamental physics constants re-exported from
astropy.constants:G,c,h,k_B,N_A,sigma_sb,m_e,m_p,M_sun,R_sun,L_sun,M_earth,R_earth,M_jup,R_jup,au,pc,kpc, and ~15 more — so one import covers fundamental + per-body without managing two parallel imports. CODATA-versioned via astropy; nothing repackaged. - Per-body PCK constants for ~145 solar-system bodies — triaxial radii, GMs, pole RA/Dec, prime meridian, rotation rate, plus polynomial coefficients — sourced from NAIF SPICE PCK kernels and versioned by IAU report edition (2009, 2015). Generated at build time from upstream kernels via
scripts/regenerate_constants.pyso the runtime never depends on spiceypy; the bareMars,Saturn, … etc. resolve to the current IAU 2015 edition. - NSSDC fact-sheet parameters for the Sun, eight planets, the Moon, and Pluto — bond albedo, surface pressure, scale height, satellite count, semimajor axis, sidereal period, ~25 more fields — merged transparently into each Body at build time. NSSDC = NASA's National Space Science Data Center at Goddard Space Flight Center; D. R. Williams has maintained the canonical per-body fact sheets there since 1996. Resolution rule: PCK wins for cartographic/orientation fields when both have a value; NSSDC fills in everything PCK doesn't carry. Every value returns as a
Constant(anastropy.units.Quantitysubclass) with.source,.reference,.description, and.iau_yearmetadata —Mars.GM.source == "pck00011.tpc",Mars.bond_albedo.source == "NSSDC marsfact.html updated 2025-05-19". Discovery helpers (planets(),moons(of="Saturn"),asteroids(),mission_visited(),find_body("Bennu")) for browsing the registry by class.
- Fundamental physics constants re-exported from
- Time-travel:
Body.at_time(date)returns a snapshot of any body with all fields resolved as ofdate. Picks the right IAU PCK edition for the date (IAU 2009 PCK published 2010-10-21; IAU 2015 PCK published 2018-09-20) AND the right NSSDC capture (most recent revision at or before the date).Mars.at_time('2012').pole_decreturns 52.8865° (IAU 2009),Mars.at_time('2024').pole_decreturns 54.4325° (IAU 2015). A 2012 paper's calculation is reproducible without the reader knowing the submodule nameiau2009exists. Adding a future IAU edition is a one-line append to_PCK_EDITION_DATES. Module-levelat_time(body, field, date)provides the function-form alternative. planetarypy.constants.nssdcopt-in namespace for users who want NSSDC-only data deliberately —nssdc.Mars.GMreturns NSSDC's own GM solution (which may differ from PCK's), not the PCK-wins-merged value.nssdc.history(body, field)returns the full publication history as(date, value, capture_url)tuples — useful for science-history studies and drift audits.nssdc.at_time(body, field, date)for NSSDC-only date lookup.- NSSDC longitudinal archive deposited at Zenodo: 10.5281/zenodo.20122987. 913 distinct content versions of NSSDC's 13 fact sheets (Sun, Mercury, Venus, Earth, Moon, Mars, Jupiter, Saturn, Uranus, Neptune, Pluto, asteroid summary, comet summary) captured via the Internet Archive's Wayback Machine, spanning December 1996 to May 2025. Bundle includes the canonical parsed JSON, a long-format CSV mirror, the original raw HTML corpus, CDX provenance manifest, and the stdlib-only Python scripts that re-derive everything from public sources. Each capture indexed by NSSDC's own "Last Updated" footer date (with Wayback timestamp as fallback for pre-2003 captures where the footer didn't exist yet). The parsed archive auto-downloads from Zenodo on first use when not already in the user's local cache — same lazy pattern as PDS index parquets. MIT-licensed (matches the planetarypy library license).
- Constants tutorial:
docs/tutorials/constants_tutorial.ipynbwalks through everyday access, provenance, discovery helpers, time-travel, paper-reproducibility, the explicitnssdcnamespace, and visualizing per-field drift with matplotlib (Saturn's satellite count from 18 in 1996 to 274 in 2025 as the headline example). - Acronym tooltips in HTML docs via a small Lua filter (
docs/_abbreviations.lua) + matching CSS (docs/_styles-abbr.css). Hovering NSSDC, NAIF, PCK, SPICE, IAU, GM, JPL, etc. shows a styled tooltip with the spelled-out form. Word-boundary safe; HTML-only (skipped for PDF/LaTeX);aria-label(nottitle) so screen readers get the expansion without the tiny native browser tooltip overlapping the styled one.
Constant.__repr__now shows source provenance for NSSDC-sourced fields. Before, the rich repr (<Constant Mars.GM = … (IAU 2015)>) requirediau_yearto be truthy, and NSSDC constants setiau_year=0since NSSDC isn't IAU-edition-versioned. NSSDC constants therefore fell back to plain Quantity repr, hiding the source. Now uses the source string as the provenance tag when no IAU year is set:<Constant Mars.bond_albedo = 0.25 (NSSDC marsfact.html updated 2025-05-19)>. The metadata-empty fallback to plain Quantity repr is preserved for un-annotated Constants.
plp catalog samples <key>— print the sample products in the catalog DB for amission.instrument.product_keytriple. Wrapsplanetarypy.catalog.example_products()in a Rich table with--phase(filter to one mission phase) and-n / --limit(cap rows; 0 = all). Useful for inspecting what's actually catalogued for archives without a registered fetch resolver, where these samples are the only available products.plp indexes peek <key>— inspect a registered PDS index's schema and a few random rows. Output is transposed (one row of the index per column of the table) so it stays readable whether the index has 4 columns (cassini.cda.index) or 71 (mro.hirise.edr). Default 3 random rows;-n / --rows Nto vary. Motivated by discovering thatcassini.cda.indexhasFILE_SPECIFICATION_NAME/DATA_SET_IDinstead of the usualPRODUCT_IDcolumn — peek surfaces the schema before you have to guess which column to use.
Index.convert_to_parquetno longer swallows conversion errors. Previously, any exception during parsing or parquet writing was caught, logged at ERROR level, and dropped. With loguru disabled-by-default in this library, that error never reached the user — the downstreampd.read_parquet(self.local_parq_path)then raisedFileNotFoundError: …/CUMINDEX.parqfrom a totally unrelated code path. Two real bugs (LAMP mixed-format times in v0.59.4, cassini.cda.index label/table mismatch below) both manifested as this misleading FileNotFoundError.convert_to_parquetnow re-raises asRuntimeErrorwith the index_key in the message and the original parser exception chained as__cause__.IndexLabel.index_pathraises a descriptive label/table mismatch error. When a label's^TABLEpointer names a file that doesn't exist on disk (under either case), the previous code logged ERROR and returned a phantom path; downstreampd.read_csvthen raised an unhelpful "No such file or directory: index.tab". Now raises aFileNotFoundErrorthat names the label, the declared table filename, what files ARE present in the directory, and labels the situation as a publishing inconsistency in the source archive — not auto-fixable in the local cache.cassini.cda.indexis the canonical case: SETI publishes the cumulative table asCUMINDEX.TABbut the includedCUMINDEX.LBLdeclares^INDEX_TABLE = "INDEX.TAB"(the per-volume convention). Combined with theconvert_to_parquetre-raise, users ofplp meta cassini.cda.index <pid>now see exactly what went wrong on the first try.
plp catalog list <mission>now distinguishes three states per instrument instead of collapsing two of them into a blank cell. The previous two-column view ("instrument" + "fetchable product types") couldn't tell apart (a) instruments with no PDS index registered at all from (b) instruments that are indexed but lack a fetch resolver (e.g.lro.lamp— its index has no VOLUME_ID column, and the archive splits acrossLROLAM_<N>volume directories that can't be derived from row data). New "registered indexes" column lists the index names from the static config; the fetchable cell now shows(no fetch resolver)when at least one index exists but noINDEX_REGISTRYentry maps it. The second state matters becauseplp meta lro.lamp.edr <pid>works for those instruments — onlyplp fetchdoesn't — and there was no way to discover that asymmetry from the catalog browse before.
get_index()/plp meta/plp example_pidnow work on indexes with mixed-format time columns (LAMP)._convert_timespreviously tried up to four format-detection strategies (auto / mixed / ISO 8601 / DOY) but each had to handle the whole column — fine for homogeneous columns, broken for indexes that mix per-row formats. LAMP'sSTART_TIMEhas ISO calendar (2009-07-06T12:47:18.250), PDS DOY (YYYY-DDDTHH:MM:SS), and the occasional garbage value ('0') all in one column. The chain failed at the strict-ISO step, the DOY fallback raised on the first non-DOY row, and the broadexceptinconvert_to_parquetswallowed the error — symptom wasFileNotFoundError: …/CUMINDEX.parqfromplp example_pid lro.lamp.edrlater, with the real cause hidden. Switched to a row-wise approach:pd.to_datetime(errors="coerce", format="mixed")parses what pandas can (turning unparseable rows to NaT), then a row-wise_safe_doyfills the NaTs with the DOY converter (NaT-tolerant — garbage that neither parser handles becomes NaT instead of aborting the whole conversion). Standard parser covers >99% of indexes; DOY is the targeted fallback for what's left.
-
Stale catalog DB now fails fast with an actionable message. When
~/planetarypy_data/catalog/<db>.duckdbwas built with planetarypy ≤ 0.52 (pre-PDS-catalog rewrite), itsproduct_typestable lacks thenormalized_typecolumn added in v0.53. Queries likeexample_products/list_products(include_phases=True)/search/ambiguous_mappingsfailed opaquely withDuckDB Binder Error: Table 'pt' does not have a column named 'normalized_type'.get_catalog()now runsPRAGMA table_info(product_types)after opening the connection and, ifnormalized_typeis missing, raises:Stale catalog DB schema (built with planetarypy ≤ 0.52). Runplp catalog build --forceto rebuild — the source-of-truth (pdr-tests + INDEX_REGISTRY) is unchanged, so no data is lost.One gate covers every catalog query (every public function routes through
get_catalog()).
-
Reverts the v0.59.1
fetch_productindex_key fallback. That patch madeplp fetch lro.diviner.edr1 <pid>succeed by mapping the index_key back to its catalog triple, but it hid a real ambiguity:edr1,edr2, andedrall returned the same row for the sameproduct_idbecause_load_index_dfalready concatenates the two parquets and searches across both. The product physically lives in only one parquet; silently returning a "match" for the wrong index_key was incorrect. Restored the strict catalog-product_key contract for fetch. -
Improved error when an index_key is passed where a catalog product_key was expected. Replaces the misleading "variable URL paths … no PDS index is available" message (an index was available, just at the catalog product_key) with a direct pointer at the right key:
'lro.diviner.edr1'is an index_key (used byplp metaandplp indexes), not a catalog product_key. For fetching use'lro.diviner.edr'. Seeplp indexes list lro.divinerfor the full index → catalog mapping.Inspection commands (
plp meta,plp indexes,plp example_pid) keep their per-parquet index_keys; fetch operates on the catalog product_key. Different scopes = different keys, by design.
plp fetchnow accepts both index_keys and catalog product_keys. The round-tripplp fetch (plp example_pid <key>) <pid>worked for instruments where the registered index_key equals the catalog product_key (mro.ctx.edr,mro.hirise.edr, …) but broke when one catalog product type was split across multiple parquets — Diviner's catalogedrtriple maps to indexesedr1+edr2.plp example_pid lro.diviner.edr1would emit a valid PID butplp fetch lro.diviner.edr1failed with "This product type has variable URL paths … no PDS index is available" becauseINDEX_REGISTRYis keyed by catalog triple, not by index_key. New_resolve_fetch_triple()looks the dotted key up first as a catalog triple, then (if absent) as anindex_keyon everyIndexConfig.index_key/extra_index_keysfield. Bothcatalog.fetch_product()andcatalog.get_product_urls()route through it.
plp catalogbrowsing subcommands — bring the (until now build-only)plp catalognamespace into parity with the API by exposing the existingplanetarypy.catalog.*browse functions as CLI verbs. Each one renders a Rich table; the catalog browse cross-references the index registry so users can see at a glance which entries are fetchable.plp catalog list [KEY]— three-level drill-down: no arg lists all 65 missions with per-mission counts and a✓ fetchableflag (set when at least one product type has anINDEX_REGISTRYentry);KEY=missionlists instruments with their fetchable variants;KEY=mission.instrumentlists product types with theirindex_keymappings.plp catalog show <KEY>— full info for amission.instrument.product_keytriple, including index_key / archive / SETI volume group / completion column / prefix-strip rule when fetchable, plus the catalog DB sample-products count.plp catalog search <QUERY>— wrapscatalog.search().plp catalog summary— wrapscatalog.summary().plp catalog ambiguous— wrapscatalog.ambiguous_mappings()(a tripwire surfacing pdr-tests folder names whose(mission, instrument)resolution falls through to the bare-name fallback at_mission_map.py:1083— empty today is the healthy signal).
plp indexessubtree — new top-level namespace for browsing the operational fetch surface (the 78 registered PDS cumulative indexes from~/.planetarypy_index_urls.toml), kept visually separate from theplp cataloginventory tree to avoid confusing "what exists in PDS" with "what we can actually fetch".plp indexes list [KEY]— three-level drill-down matchingplp catalog list: missions → instruments → indexes. Themission.instrumentlevel surfaces which indexes are cached locally with on-disk size and which have a catalogproduct_keyentry.--treefalls back to the legacyprint_available_indexes()tree.plp indexes info <KEY>—IndexConfig+ cache status for a single registered index, including remote URL, completion column, prefix-strip rule, archive base, and reverse-lookup catalog entries that map to this index.plp indexes refresh [--config|--cache <KEY>]— explicit force-refresh of either the upstreamplanetarypy_index_urls.toml(normally auto-refreshed once per day) or a single index's cumulative.lbl/.tab+ parquet rebuild.
plp catalogandplp indexes(bare invocation) now print help instead of erroring. Both subapps lackedno_args_is_help=True, soplp catalogandplp indexesexited 2 with a "Missing argument" message rather than the subcommand list. Now mirrors the parentapp's behavior.
get_example_pid()now returns the canonical user-facing PID form (the same shapecomplete_pidcaches andget_metaaccepts), instead of stopping at_bare_pid(path/extension and version-suffix only). Indexes with anIndexConfig.pid_strip_prefix_re— currently the two cassini.iss variants — were emitting the un-stripped form:plp example_pid cassini.iss.indexreturned1_N1454725799while the canonical form isN1454725799, so the natural round-tripplp meta cassini.iss.index (plp example_pid cassini.iss.index)carried a stale prefix into the meta query. CTX, HiRISE, UVIS, etc. unchanged because they have no prefix-strip rule.
- Follow planetarypy_configs#1 — Cassini canonical-key rename. Three sections in the upstream
planetarypy_index_urls.tomlpreviously violated themission.instrument.indexnamedotted-key shape by encoding mission phase or activity mode in the instrument slot. After upstream merge,INDEX_REGISTRY's("cassini", "iss", "edr_evj")entry now points atcassini.iss.cruise_indexinstead ofcassini.iss_cruise.index. UVIS and VIMS occultation variants (uvis_occ,vims_occ) had noINDEX_REGISTRYentries to update — they were only reachable through the static-index registry — so they get the canonical key shape (cassini.uvis.occ_index,cassini.vims.occ_profile_index, etc.) for free on the next config refresh. Local users with cached parquets at{storage_root}/cassini/{iss_cruise,uvis_occ,vims_occ}/...will harmlessly orphan them and re-download under the new paths on first access (~50 MB across all three).
plp meta <key> <product_id>andplanetarypy.pds.get_meta()— print the metadata row for a product from any registered PDS cumulative index, rendered as a Rich two-column table. Identifier (*_ID,FILE_NAME,PATH_NAME) fields are surfaced first, then*ANGLE*fields, then everything else. Matching is tolerant of case differences and PDS path/extension/version-suffix decoration. HiRISE EDR/RDR is special-cased via a per-instrument display registry: a bare obsid yields a short per-color summary across the observation (RED / BG / IR rows forIMAGE_LINES,LINE_SAMPLES,SCALED_PIXEL_WIDTH);--longreturns the RED3_1 channel's full row; a channel-suffixed PRODUCT_ID returns that exact row. Non-instrument-specific indexes go through the generic path, so any registered index works out of the box.planetarypy.pds.read_index_slice(index_key, filters=None, columns=None)— column-projected, predicate-pushed-down parquet read for any registered index. Use this instead ofget_index()when you need a few columns or rows: a per-obsid HiRISE EDR lookup goes from ~3.3 s (load all 86 MB / 2.6 M rows) to ~0.03 s (28 rows via row-group skipping) — a ~100× speedup. The HiRISE meta path uses this internally; fullplp metacalls on HiRISE EDR drop from ~4 s to ~0.2 s (CTXplp metaunchanged at ~0.4 s).planetarypy.pds.complete_pid(incomplete, index_key)— generic shell tab-completion for any registered PDS index. Backed by a sorted text cache built lazily on first use from the index's configured completion column, with_bare_pidnormalization (strips PDS path/extension and trailing.NNNversion suffixes) and per-index prefix stripping (seepid_strip_prefix_rebelow). Replaces the per-instrumentcomplete_obsid()/complete_ctx_pid()functions; CTX, HiRISE EDR/RDR, UVIS, ISS, and every other registered index now tab-complete uniformly. Lookups are sub-millisecond after the one-time cache build.planetarypy.instruments.mro.hirise.browse_url(product_id, annotated=True)— single source of truth for HiRISE EXTRAS browse-JPEG URL construction.get_browse()now calls it, removing duplicated URL formatting that previously lived in theplp hibrowseCLI command.planetarypy.instruments.mro.hirise.create_mosaics(obsid, colors=("red",), ccds=None, ...)— plural orchestrator that owns the per-color loop and the "ccds applies only to RED" rule.plp himoscollapses to flag-mapping plus a single API call.local_dirkwarg onplanetarypy.catalog.fetch_product()— overrides the storage path. Theplp fetch --here/-Hflag now maps cleanly through the public API instead of reaching into private resolver internals.IndexConfig.completion_id_col— column to surface for tab completion when it differs fromproduct_id_col. HiRISE EDR/RDR/DTM set it toOBSERVATION_IDso users complete a 15-char obsid instead of one of the 28 channel-level PIDs per observation.IndexConfig.pid_strip_prefix_re— regex applied after_bare_pidto strip a per-index leading housekeeping prefix from both stored values and user input. cassini.iss / cassini.iss_cruise set it tor"^.{1}_"so the stored1_N1454725799.122and the typedN1454725799both normalize to the same canonical form.- Per-instrument meta-display registry at
planetarypy.pds.meta_display.get_handler(index_key)— routesget_metato a custom shaping function when an index needs more than the generic two-column dump. Currently HiRISE EDR/RDR; the registry is the extension point for future instrument-specific summaries.
get_example_pid()skips CRU-prefixed cruise PIDs. Cruise-phase product IDs (e.g. CTX EDR'sCRU_000001_9999_XN_99N999W) aren't representative examples of an instrument's typical mapping output. They're now treated like the existingUNKplaceholder skip — preferred non-CRU rows, with fallback if every row is CRU.plp example_pid mro.ctx.edrnow returnsMOI_000009_0186_XI_18S051Winstead ofCRU_000001_9999_XN_99N999W.- CLI commands moved behind public APIs.
plp fetch,plp hibrowse, andplp himosno longer import private resolver/instrument symbols. CLI body is purely flag-to-API mapping plus output formatting; everythingplpcan do, the API can do directly. Per-instrument completion code (complete_obsid,rebuild_obsid_cache,_obsid_cache_pathinhirise.py;complete_ctx_pid,rebuild_pid_cache,_pid_cache_pathinctx_edr.py) is removed in favor of the generic registry-driven path.
- Eager-tuple performance bug in the generic
get_metamatcher. The three matching passes (exact / case-insensitive / bare-PID) were built as a tuple literal, so all three were always evaluated even when the cheapest one would have won — the per-row Pythonapply(_bare_pid)pass burned ~4 s per column on the 2.6 M-row HiRISE EDR index for nothing. Replaced with short-circuit if/elif and a sample-based "skip the apply pass entirely if neither side is decorated" probe. - HiRISE RDR meta queries no longer crash. RDR has a different schema than EDR (post-mapping with
MAP_*andMINIMUM/MAXIMUM_LAT/LONinstead ofIMAGE_CENTER_*; merged-color rows with noCCD_NAME/CHANNEL_NUMBER/SCALED_PIXEL_WIDTH), so the EDR-shaped column projection raisedArrowInvalid: No match for FieldRef.Name(IMAGE_CENTER_LATITUDE).format_metanow routes EDR and RDR separately: RDR matches color-suffixed PIDs (..._RED/..._COLOR/..._IRB) directly and falls back to the obsid's_REDrow for bare-obsid input.
list_products(<key>, include_phases=True)now returns asourcecolumn carrying the pdr-tests definition folder (e.g.dawn__virvsdawn_certified__vir). Some instruments have parallel archive provenances for the same logical product type — the previous DataFrame projected onlynormalized_type / phase / format / product_key, so those rows looked like exact duplicates even though they pointed at different URL paths. Callinglist_products("dawn.vir", include_phases=True)now plainly shows e.g.edr / dawn__virandedr / dawn_certified__viras the two distinct sources of anedrrow.
-
plp fetch --folder/-dflag prints the local folder on stdout (single line) instead of the per-file absolute paths. Composes with shellcd:cd (plp fetch --folder mro.ctx.edr P02_001916_2221_XI_42N027W)
Default behavior is unchanged — without the flag,
plp fetchstill emits one absolute file path per line, soqgis (plp fetch …)style multi-arg command substitution keeps working.
-
planetarypy.catalog.fetch_product()now returns aDownloadedProductdataclass instead of a barePath. The CLI already printed absolute file paths to stdout (soqgis (plp fetch …)shell substitution worked) but the API silently discarded the file list and gave callers only the directory. The new bundle plumbs both pieces through:result.product_id— canonical PID the resolver matched (post bare-PID normalization).result.local_dir—Pathto the folder.result.files—list[Path]of every file actually written by this call (subset whenlabel_only=Trueor an explicitfiles=filter is passed).result.label_file— convenience pointer to the PDS.LBL/.XMLif it was among the downloaded files, elseNone.
Migration: callers that previously did
path = fetch_product(...)should switch toresult.local_dir. The new dataclass is also re-exported asfrom planetarypy.catalog import DownloadedProductfor type hints /isinstancechecks.
planetarypy.pds.get_example_pid(instr_key)— generic helper that returns a sample product ID for any index registered in~/.planetarypy_index_urls.toml(or the dynamic handler registry). Useful as a seed forplp fetchdemos, notebook examples, smoke tests, and tab-completion fixtures — previously each instrument module had to ship its own ad-hoc example PID. Resolves the product-id column via the catalogINDEX_REGISTRYwhen available (so non-standard cases likecassini.uvisusingFILE_NAMEas the PID column are handled correctly), then falls back toPRODUCT_ID/FILE_NAME/IMAGE_ID/OBSERVATION_ID. Skips"UNK"placeholder rows (e.g. early Galileo SSI cruise frames whosePRODUCT_IDis literally"UNK") but degrades gracefully if every row is UNK. RaisesValueErroron unknown index keys.plp example_pid <key>— CLI surface for the same. Prints the PID to stdout (so it composes withplp fetch), exits non-zero on unknown keys, and supports tab-completion over the registered dotted index keys.
- PIDs are now normalized to a bare canonical form at both ends of the round trip.
get_example_pidand the catalog_find_product_in_index/resolve_from_indexgo through a shared_bare_pidnormalizer soplp example_pid <key>output composes directly withplp fetch <key> <pid>. Two-step rule:- If the value's basename ends in a known PDS file extension (
.LBL .IMG .TAB .DAT .FIT .JP2 .QUB .XML), strip path + extension. (e.g.cassini.uvis.indexFILE_NAMEof/COUVIS_0001/.../EUV1999_007_17_05.LBL→EUV1999_007_17_05.) - Else strip a trailing
.<digits>version suffix only. (e.g.cassini.iss.indexPRODUCT_ID1_N1454725799.122, where.122is the FLIGHT_SOFTWARE_VERSION_ID, →1_N1454725799.) - Else preserve the value verbatim — keeps slashes intact when they're PID separators rather than paths (e.g.
mgs.moc.edrFHA/00435,cassini.vims.index1/1294638283_1). An earlier naïve form mishandled this and would have collapsed 7677 distinct MGS MOC PIDs into a single bare form.
- If the value's basename ends in a known PDS file extension (
ResolvedProduct.product_idreturned fromresolve_from_indexis now also the bare form, so the per-product storage folder created by_local_product_dirno longer contains nested archive paths for indexes whose PID column stores a full FILE_NAME.
src/planetarypy/__init__.py.__version__was stuck at0.41.2because no[[tool.bumpversion.files]]entries existed in the existing[tool.bumpversion]config; recent releases (0.53.5–0.53.7) bumped onlypyproject.toml. Resynced and now wired so futurebump-my-version bump <part>runs keep both files in sync.
- Race-safe parquet/csv cache writes. When parallel test workers (pytest-xdist) both triggered a first-time PDS index download (e.g.
get_index("mro.hirise.rdr")fromtest_spicer.TestSolarAzimuth), two workers would finish downloading the.lbl+.tabfiles and simultaneously calldf.to_parquet(path)into the same file. The non-atomic write produced a torn parquet, with the next reader hittingOSError: Couldn't deserialize thrift: TProtocolException: Invalid data. Same class of race existed for the SPICE archived-kernelsdatasets.csvcache viadf.to_csv(path). Both are now routed through a newplanetarypy.utils.atomic_writecontext manager: each writer writes to a per-PID scratch file and atomically renames into place; the first concurrent finisher wins, later finishers silently drop their copy. Verified under an 8-thread stress test.
planetarypy.utils.atomic_write(path)— context manager yielding a per-PID scratchPath; on clean exit, atomically renames it ontopath. Reusable for any library-level cache write that can race.
plp ctx-migratenow walks eachmrox_*volume recursively, so files already nested in<pid>/subfolders are counted as "already in place" instead of being silently skipped. Before this fix, a post-migration re-run reportedalready in place: 0, which looked alarming even though no moves were needed. After: the summary reflects the actual number of pid-matching files found.
-
Separate mirror/local config for CTX EDR in
~/.planetarypy_mro_ctx.toml. The old single[edr]section conflated three things (mirror layout, local layout, download URL) under one set ofwith_volume/with_pidtoggles, so users couldn't e.g. keep the shared read-only mirror in canonical PDS layout while storing new downloads next to their calib outputs. Two new optional sub-tables decouple them:[edr] url = "https://pds-imaging.jpl.nasa.gov/data/mro/ctx" [edr.mirror] # read-only; may be unmounted path = "/Volumes/planet/Mars/CTX/pds" with_volume = true with_pid = false with_data_segment = false # PDS canonical "<vol>/data/<pid>.IMG" [edr.local] # writeable; where new downloads go path = "" # "" → {storage_root}/mro/ctx with_volume = true with_pid = true # co-locate raw EDR with calib outputs
The legacy flat shape (
local_mirror,local_storage, top-levelwith_volume/with_pid) is still read transparently — no user TOML edit is required. Opt in by adding the sub-tables when ready. -
plp ctx-migrate [--dry-run]— one-shot utility that walks eachmrox_*volume folder under the configured EDR local root and relocates any file named<pid>.<ext>(26-char CTX product_id, so.IMG,.cub,.lev1.cub,.lev2.cub,.lev2.tif,.lev1.gml,.csm2map.tif, etc.) to whatever layout the active config dictates. Idempotent; conflicts are skipped with a warning rather than overwriting.
- Internal CTX path helpers refactored from
ctx_storage_folder(level, …)+_level_base(level)into three small readers_edr_mirror_folder/_edr_local_folder/_calib_folderplus a 7-line_apply_toggleshelper. No external API change forEDR/Calibcallers.
url_retrieveis now concurrency-safe. Previously two processes (e.g. parallel pytest-xdist workers hittingload_generic_kernels()viaSpicer("MARS")/Spicer("MOON")) could clobber each other's.partscratch file and race on the finalrename(), producingFileNotFoundError. The scratch file now includes the writer's PID ({name}.{pid}.part), and when a concurrent winner has already moved the final file into place the loser silently drops its scratch copy instead of raising. This was the root cause of intermittent CI failures intest_spicer.
- CI workflow
test.yamlnow prefetches the SPICE generic kernels in a single-writer step before invokingpytest, so parallel test workers see cached files and never trigger the download path simultaneously. Complements theurl_retrievefix; either alone would green CI, both together harden the library for any concurrent caller.
plp fetchandplp hibrowsenow emit only the resolved file path on stdout. Diagnostic lines ("Resolving…", "URL:…", "Fetching…") and the "Browse:" prefix previously mixed with the final path on stdout, which made shell command substitution clumsy (e.g.qgis (plp fetch mro.ctx.edr <pid>)captured all of it as arguments). Diagnostics now go to stderr; only the payload path hits stdout.
- CTX storage path mismatch between
plp fetchandctxqv/EDR.plp fetch mro.ctx.edr <pid>previously wrote to{storage_root}/mro/ctx/edr/<pid>/(the catalog's generic layout), whileEDR(pid).local_storage_folder(used byplp ctxqvand programmatic access) wrote to{storage_root}/mro/ctx/<volume>/…per~/.planetarypy_mro_ctx.toml. Downloads from one code path were invisible to the other, causing redundant re-downloads. Both paths now resolve through the singlectx_storage_folder(level, volume, pid)helper and land in the same directory.
- CTX storage layout for
plp fetch mro.ctx.edrnow follows~/.planetarypy_mro_ctx.toml([edr].local_storage,[edr].with_volume,[edr].with_pid) instead of the generic catalog fallback. A new_ctx_local_product_dirresolver is registered inplanetarypy.catalog._resolver._STORAGE_RESOLVER_MODULES. - CTX config (
CTXCONFIG, mirror reachability) is now read lazily on each access rather than snapshotted at import — mounting or unmounting the local mirror mid-session is reflected immediately, andwith_volume/with_pidconfig edits take effect without re-importing. EDR.with_volume,EDR.with_pid,Calib.with_volume,Calib.with_pidare now@propertyreads ofCTXCONFIG(previously snapshotted in__init__).
- Shadow duplicate
EDRclass inplanetarypy.instruments.mro.ctx.ctx_calib(leftover from the original module split).EDRis now defined once inctx_edr.pyand imported from there. - Module-level globals
STORAGE_ROOT,EDR_LOCAL_STORAGE,EDR_LOCAL_MIRROR,MIRROR_READABLE,MIRROR_WRITEABLEinctx_edr.pyand their lowercase counterparts inctx_calib.py. Replaced by lazy accessors (_storage_root,_edr_local_mirror,_mirror_readable,_level_base).
- If you previously ran
plp fetch mro.ctx.edr <pid>and have existing downloads under{storage_root}/mro/ctx/edr/<pid>/, they will not be picked up by the new layout. Either move them to{storage_root}/mro/ctx/<volume>/[<pid>/](per your[edr].with_volume/[edr].with_pidtoggles), delete them, or re-runplp fetchto download into the new location.
- Only show satellite ephemeris download message when actually downloading (not when loading from cache)
- Spicer now works for outer solar system bodies (Jupiter, Saturn, Neptune, Pluto systems) — satellite ephemeris SPKs are downloaded on demand when needed
- Graceful fallback in CLI when SPICE ephemeris data is missing (shows what it can instead of crashing)
- Suppress pvl PendingDeprecationWarning (pvl#109)
Dedicated to the memory of Candice J. Hansen — scientist, mentor, and friend. This release was built in a single long push fueled by the urgency that reminds us our time to contribute is finite.
- Spicer class (
planetarypy.spice.spicer): surface illumination calculator for any solar system bodySpicer("MARS").illumination(lon, lat, time)— solar incidence, flux, L_s, local timeslopeandaspectparameters for tilted surface flux (south-facing slopes etc.)solar_azimuth_at(lon, lat, time)— SPICE-computed solar azimuth, validated to <1° against HiRISE index.Lsproperty for current solar longitudesun_direction_at()for azimuth calculation via Point classillumination_at(point)integration withplanetarypy.geo.Pointsupported_bodies()— discover all bodies available from loaded kernels (79 from generic PCK)units=Truetoggle for astropy Quantity output- Rotation via scipy (matching SPICE right-hand convention)
- CLI:
plp spicer Mars— current L_s, subsolar point, solar constant; add--lon --latfor surface illumination - Spicer tutorial with diurnal flux curve, slope/aspect comparison, multi-body demo, and HiRISE index validation
- Geospatial module (
planetarypy.geo): GDAL-free coordinate transforms built on rasterio + pyprojpixel_to_xy,xy_to_pixel,pixel_to_lonlat,lonlat_to_pixel,xy_to_lonlat,lonlat_to_xyis_within(source, lon, lat): check if coordinates fall within an imageimage_azimuth: clockwise from north (standard planetary science)image_azimuth_cw_from_right: clockwise from 3 o'clock (HiRISE convention)pixel_resolution: pixel size from affine transform- Works with IAU 2015 planetary CRS codes (Mars, Moon, any solar system body)
- Point class (
planetarypy.geo.Point): CRS-aware geographic point- Create from lon/lat, pixel coordinates, or projected coordinates
.to_xy(),.to_pixel(),.is_within(),.azimuth_to(),.to_shapely()- Auto-resolves pixel↔lonlat when source DataArray is provided
- Plotting module (
planetarypy.plotting): visualization helpersimshow_gray: grayscale image display with percentile stretchpercentile_stretch: reusable stretch calculationadd_sun_indicator: sun direction overlay on any axesimshow_with_sun: combined image display + sun indicator
- HiRISE instrument module (
planetarypy.instruments.mro.hirise)get_browse(pid, annotated=True): download browse JPEG (annotated or clean)get_metadata(pid): look up index metadatasun_azimuth_from_top(pid): convert HiRISE CW-from-right to CW-from-top
- CLI:
plp hibrowse --annotated/--cleanoption for browse variant - Geospatial tutorial: pixel↔lonlat transforms, IAU CRS codes, Point class, sun indicator verification with real HiRISE data (ESP_013807_2035)
rasterio,pyproj,rioxarrayadded to core dependencies
plp hibrowseandplp hifetchnow useplanetarypy.instruments.mro.hirisemodule instead of inline CLI helpersplp ctxqvusesplanetarypy.plotting.imshow_grayinstead of duplicating stretch logic
- Move duckdb from optional
[catalog]extra to core dependency (was breaking installs) - Pandas 2.x compatibility: datetime64 resolution (ns→us) and string dtype (object→StringDtype) in test assertions
- Mock catalog DB in tests that hit
get_catalog()(fixes CI without a built catalog) - Stale docs: rewrite pds_index_config explanation, update landing page, fix tutorial tier numbering
- Logo in docs navbar and browser favicon
Breaking change: This is a ground-up rewrite with a new API. The previous version (0.32.x) based on nbplanetary remains at github.com/michaelaye/nbplanetary.
- PDS Catalog module (
planetarypy.catalog): comprehensive index of ~2000 product types across 200+ instruments from the entire PDS archive, built from MillionConcepts pdr-tests repository into a local DuckDB databasebuild_catalog()to clone pdr-tests and populate the database- Query API:
list_missions(),list_instruments(),list_products(),example_products(),search(),summary() - Dotted key access:
list_products("mro.hirise"),example_products("cassini.iss.edr_sat") - 150+ manual mission/instrument mappings for pdr-tests folder names
- AST-based parser for selection_rules.py (no code execution)
- Multi-instrument folder splitting: folders like
mrocorrectly split intoctx,hirise,marci,mcsinstruments based on product key prefixes; infix matching for Rosetta-styleEDR_instrumentkeys - Product key normalization: decompose keys like
edr_sat,sat_rdr_ascinto 3 dimensions —normalized_type(data type),phase(target body/mission phase),format(ascii/binary/coordinate system). Recognized phases include planets (saturn, jupiter, neptune, uranus, earth, pluto), minor bodies (ceres, vesta, gaspra, ida, arrokoth, phobos, halley), and mission phases (cruise, launch, kem_cruise) MissionandInstrumentobjects with human-readable full names for all 65 missions and ~180 instruments (e.g.Mission("mro").full_name→ "Mars Reconnaissance Orbiter",mro["ctx"]→ "Context Camera")- Rosetta Lander (Philae) as separate mission entry with 8 properly split instruments
- Voyager POS ephemeris override: standalone body keys normalized to
"ephemeris"for pre-SPICE position data - Generic instrument groupings (spectrometers, particles, plasma, probe, dust, lander) decomposed into real instrument names across Galileo, NEAR, Rosetta, Voyager, Dawn, Deep Impact
_miscinstruments hidden fromlist_instruments()by default; accessible viainclude_misc=TrueorMission.misc- URL rewrite for broken USGS Imaging Node URLs (60 of 69 rewritten to SETI Rings and JPL Planetary Data mirrors)
- Product download API:
fetch_product("mission.instrument.type", product_id)downloads files and returns local path,get_product_url()returns remote URL,list_product_files()returns file-to-URL mapping - Index-backed resolution (Tier 2): 58 product types across 29 instruments on 15 missions — arbitrary product IDs resolved via PDS cumulative indexes for CTX, HiRISE, Cassini ISS, Galileo SSI, LROC, Diviner, CRISM, LOLA, Cassini UVIS/VIMS/CIRS, Voyager 1&2 ISS, Juno JunoCam, New Horizons LORRI, MER Pancam, MGS MOC, Viking VIS, MESSENGER MDIS, Cassini RSS, Phoenix MECA instruments (WCL/AFM/TECP/ELEC), and MSL (APXS, ChemCam, CheMin, SAM)
- Pattern-based URL resolution (Tier 3): for product types with fixed
url_stem, resolve arbitrary product IDs without needing a PDS index - Per-archive URL construction:
IndexConfigsupportspath_name_col(for FILE_NAME + PATH_NAME split indexes),lowercase_paths/lowercase_filesflags,volume_id_col=""to skip volume in URL, andseti_volume_group="auto"to derive SETI volume groups dynamically - Verified HTTP 200 for all 50 testable registry entries (7 indexes not yet downloaded, 1 removed)
- Explanation doc:
docs/explanation/product_url_resolution.qmdwith full direct data access status table - Tutorial notebook in
docs/tutorials/pds_catalog_tutorial.ipynb
- Unified CLI (
plp): single entry point built on typerplp fetch mro.ctx.edr PRODUCT_ID— download any product by dotted keyplp fetch --here— download into current directoryplp hibrowse PSP_003092_0985_RED— fetch HiRISE browse JPEG from EXTRAS, opens in Preview on macOSplp hifetch PSP_003092_0985_RED— fetch full HiRISE data productplp ctxqv J05_046771_1950— CTX quickview with strided memmapplp catalog build— build/rebuild the catalog database
- CTX quickview (
EDR.quickview,Calib.quickview): memory-mapped strided reads for fast previews - Dynamic URL handlers for LRO LAMP EDR and RDR indexes (volume-based URLs at JPL)
- Backup URL fallback for CTX index (pdsimage2.wr.usgs.gov)
- Quarto documentation with Diátaxis framework structure
- New how-to guides: CTX calibration, CTX EDR, ISIS autoseed
- Comprehensive API reference documentation
slowpytest marker for test filtering- SPICE datasets daily caching system
- Case-insensitive mission name resolution for SPICE
- Renamed
list_product_types()→list_products()in catalog API - Restructured catalog internals:
_download.py→_resolver.py,_index_bridge.py→_index_resolver.py,_url_patterns.py+_url_examiner.py→_pattern_resolver.py - Replaced click with typer for CLI; single
plpentry point replacesplp_update_indexes,plp_build_catalog,ctxqv - Migrated documentation from Sphinx to Quarto
- Split ctx.py into separate EDR and calibration modules
- Renamed HISTORY.md to CHANGELOG.md
- Adopted Keep a Changelog format
- Dead code:
exceptions.py(unused),pds/cli.py(broken imports),scripts/package, old Click-based CLIs
- Index bridge URL construction: correct case handling per archive server (lowercase paths for JPL/WUSTL, preserve case for SETI Rings/HiRISE), proper volume_id/path_name column support, and use actual filenames from index instead of guessing extensions
- Ambiguous product resolution: raises
MultipleProductsErrorwhen an ID matches multiple products (e.g. HiRISE observation matching both RED and COLOR) - Canonical PID casing: local storage paths use the index's canonical case, not the user's input
- Removed LRO LAMP from index registry (index lacks volume mapping needed for URL construction)
- Cloudflare 403 errors via User-Agent header in remote timestamp checks
- Repeated Index instantiations in dynamic handler
- Test failures from outdated DataFrame schema
- pooch dependency for data downloads
- Repeated Index instantiations in dynamic index handler
- First release on PyPI
- PDS index management system
- SPICE kernel utilities
- Basic configuration management
Note: The following versions are from predecessor projects that evolved into the current planetarypy. They are included for historical reference and are not installable from this repository.
Using nbdev (notebook-driven development) for a complete rewrite.
- v0.27.0 (2023-06) - BepiColombo SPICE kernels, PDW2023 tutorials
- v0.26.0 (2023-03) - CRISM and LROC index handlers
- v0.25.0 (2023-02) - Spicer class, ISIS integration via kalasiris
- v0.21.x (2022-02) - Full instrument modules: CTX, HiRISE, UVIS, CISS, Diviner
Private collection of planetary science tools, inspired by astropy's organization.
- v0.9.0 (2020-07) - North azimuth and sun angle calculations
- v0.8.0 (2020-07) - GeoTools module from pymars (coordinate transforms)
- v0.7.0 (2020-06) - url_retrieve with progress bar and timeout
- v0.6.0 (2020-05) - Powerful Index class, config system
- v0.5.0 (2019-07) - Renamed to planetarypy (from planetpy)
- v0.4.0 (2019-03) - PDS index download CLI, Cassini ISS indices
- v0.1 (2015-04) - NASA factsheet planetary constants parser