planetarypy 0.76.2__tar.gz → 0.77.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {planetarypy-0.76.2 → planetarypy-0.77.0}/CHANGELOG.md +22 -0
  2. {planetarypy-0.76.2 → planetarypy-0.77.0}/PKG-INFO +1 -1
  3. {planetarypy-0.76.2 → planetarypy-0.77.0}/pyproject.toml +3 -2
  4. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/__init__.py +1 -1
  5. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/cli.py +80 -0
  6. planetarypy-0.77.0/src/planetarypy/datasets/__init__.py +432 -0
  7. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/instruments/mro/hirise.py +8 -0
  8. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/plotting.py +66 -47
  9. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/search.py +86 -1
  10. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/spice/archived_kernels.py +27 -9
  11. planetarypy-0.76.2/src/planetarypy/instruments/go/__init__.py +0 -0
  12. planetarypy-0.76.2/src/planetarypy/instruments/mro/__init__.py +0 -0
  13. {planetarypy-0.76.2 → planetarypy-0.77.0}/.gitignore +0 -0
  14. {planetarypy-0.76.2 → planetarypy-0.77.0}/AUTHORS.md +0 -0
  15. {planetarypy-0.76.2 → planetarypy-0.77.0}/LICENSE +0 -0
  16. {planetarypy-0.76.2 → planetarypy-0.77.0}/README.md +0 -0
  17. {planetarypy-0.76.2 → planetarypy-0.77.0}/docs/README.md +0 -0
  18. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/__init__.py +0 -0
  19. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_index_resolver.py +0 -0
  20. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_mission_map.py +0 -0
  21. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_objects.py +0 -0
  22. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_parser.py +0 -0
  23. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_pattern_resolver.py +0 -0
  24. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_repo.py +0 -0
  25. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_resolver.py +0 -0
  26. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_schema.py +0 -0
  27. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_url_rewrite.py +0 -0
  28. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_validation.py +0 -0
  29. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/config.py +0 -0
  30. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/constants/__init__.py +0 -0
  31. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/constants/_gm_jpl.py +0 -0
  32. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/constants/base.py +0 -0
  33. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/constants/iau2009.py +0 -0
  34. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/constants/iau2015.py +0 -0
  35. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/constants/nssdc/__init__.py +0 -0
  36. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/constants/nssdc/_loader.py +0 -0
  37. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/crs.py +0 -0
  38. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/data/__init__.py +0 -0
  39. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/datetime_format_converters.py +0 -0
  40. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/geo.py +0 -0
  41. {planetarypy-0.76.2/src/planetarypy/instruments → planetarypy-0.77.0/src/planetarypy/instruments/go}/__init__.py +0 -0
  42. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/instruments/go/ssi.py +0 -0
  43. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/instruments/mro/ctx/__init__.py +0 -0
  44. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/instruments/mro/ctx/ctx_calib.py +0 -0
  45. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/instruments/mro/ctx/ctx_edr.py +0 -0
  46. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/instruments/utils.py +0 -0
  47. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/io.py +0 -0
  48. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/isis/autoseed.py +0 -0
  49. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/__init__.py +0 -0
  50. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/dynamic_index.py +0 -0
  51. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/dynamic_url_handlers.py +0 -0
  52. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/index_fixes.py +0 -0
  53. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/index_labels.py +0 -0
  54. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/index_logging.py +0 -0
  55. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/index_main.py +0 -0
  56. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/meta_display.py +0 -0
  57. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/static_index.py +0 -0
  58. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/utils.py +0 -0
  59. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/psa.py +0 -0
  60. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/spice/__init__.py +0 -0
  61. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/spice/_deps.py +0 -0
  62. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/spice/config.py +0 -0
  63. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/spice/generic_kernels.py +0 -0
  64. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/spice/pckernels.py +0 -0
  65. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/spice/spicer.py +0 -0
  66. {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/utils.py +0 -0
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.77.0] - 2026-06-18
9
+
10
+ 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.
11
+
12
+ ### Added
13
+
14
+ - **Spatial search of the NASA PDS registry.** `planetarypy.search_products(bbox=(west, south, east, north))` filters by footprint overlap via the registry's `cart:Bounding_Coordinates` fields (degrees; shapely/GeoJSON order). `planetarypy.search.bbox_from_point(lon, lat, radius_deg)` builds a box around a point, and `planetarypy.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.
15
+ - **`plp search at BODY LON LAT`** — "what PDS data exists at this coordinate?" from the command line, with `--radius`, `--instrument`, `--count`, and `--limit`. `BODY` is a planet name (mapped to its target LID) or a full target LID; negative coordinates are accepted as positionals.
16
+ - **`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) and `StacCollection` (a STAC collection of many COGs resolved by location — the USGS Astrogeology `mo_themis_controlled_mosaics` and `mro_ctx_controlled_usgs_dtms` for Mars, `lunar_orbiter_laser_altimeter` for the Moon). `read_window(lon, lat, size, anchor="center"|"sw"|"nw"|"se"|"ne")` and `read_bbox(west, south, east, north)` accept a `RemoteRaster`, a `StacItem`, 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 rioxarray `DataArray` (or write a GeoTIFF with `out=`). Access is by body: `datasets.mars.hrsc_level3`, `datasets.moon.lola_dtms`, …. This is the first slice of the `planetarypy.datasets` design; a remote-refreshed registry, a download mode, and a `plp datasets` CLI are planned.
17
+
18
+ ## [0.76.3] - 2026-06-17
19
+
20
+ ### Fixed
21
+
22
+ - **PDS registry search: multi-filter queries no longer fail with HTTP 400.** `planetarypy.search` built its query string by joining clauses with ` and `, 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) returned `400 UnparsableQParamException` and only single-clause lookups worked. The query builder now wraps the joined clauses in `(...)`, matching what `pds.peppi` does. This also unblocks spatial searches via the `cart:Bounding_Coordinates.cart:*_bounding_coordinate` fields.
23
+ - **Archived SPICE metakernels with non-`'./data'` path conventions now load.** `archived_kernels.get_metakernel()` rewrote kernel paths by matching the literal `'./data'` in `PATH_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 broke `furnsh`. The rewrite is now convention-agnostic: it repoints whatever value the `PATH_VALUES` block holds to the absolute local kernel directory, leaving `KERNELS_TO_LOAD` symbol references intact.
24
+ - **`plotting.add_sun_indicator` no 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 latent `origin='upper'`-only assumption).
25
+
26
+ ### Changed
27
+
28
+ - **`plotting.add_sun_indicator` / `imshow_with_sun` are marked experimental.** They now carry an experimental note and emit a one-shot `UserWarning`. The azimuth-convention handoff is not yet validated end-to-end: these helpers expect azimuth clockwise-from-image-top (PDS `SUB_SOLAR_AZIMUTH`), whereas `Spicer.solar_azimuth_at` returns clockwise-from-north — they agree only when image-north points up.
29
+
8
30
  ## [0.76.2] - 2026-06-12
9
31
 
10
32
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: planetarypy
3
- Version: 0.76.2
3
+ Version: 0.77.0
4
4
  Summary: Core package for planetary data tools. Includes PDS index utilities, SPICE integrations, and more.
5
5
  Project-URL: Homepage, https://github.com/planetarypy/planetarypy
6
6
  Project-URL: Repository, https://github.com/planetarypy/planetarypy
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "planetarypy"
7
- version = "0.76.2"
7
+ version = "0.77.0"
8
8
  description = "Core package for planetary data tools. Includes PDS index utilities, SPICE integrations, and more."
9
9
  readme = "README.md"
10
10
  requires-python = ">= 3.11, <4"
@@ -185,6 +185,7 @@ DEP001 = [
185
185
  # DEP002 = "declared dependency is not used in the codebase"
186
186
  DEP002 = [
187
187
  "lxml", # used at call time by pandas.read_html in spice/archived_kernels.py
188
+ "pyarrow", # parquet engine loaded internally by pandas to_parquet/read_parquet; never imported directly
188
189
  "pytest", # CLI tool used in CI / dev; not imported from src
189
190
  "pytest-cov",
190
191
  "pytest-xdist",
@@ -201,7 +202,7 @@ DEP003 = [
201
202
  ]
202
203
 
203
204
  [tool.bumpversion]
204
- current_version = "0.76.2"
205
+ current_version = "0.77.0"
205
206
  commit = true
206
207
  tag = true
207
208
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  __author__ = """PlanetaryPy Developers"""
4
4
  __email__ = "kmichael.aye@gmail.com"
5
- __version__ = "0.76.2"
5
+ __version__ = "0.77.0"
6
6
 
7
7
  __all__ = [
8
8
  "enable_logging",
@@ -876,6 +876,86 @@ def search_get_cmd(
876
876
  typer.echo(f" ({len(props)} properties)")
877
877
 
878
878
 
879
+ @search_app.command(
880
+ "at",
881
+ # Let negative coordinates (e.g. lat -85, lon -120) be parsed as positional
882
+ # arguments rather than mistaken for short option flags ("-8").
883
+ context_settings={"ignore_unknown_options": True},
884
+ )
885
+ def search_at_cmd(
886
+ ctx: typer.Context,
887
+ body: str = typer.Argument(None, help="Body name (e.g. Mars) or full target LID."),
888
+ lon: float = typer.Argument(None, help="Longitude, degrees (PDS cart convention)."),
889
+ lat: float = typer.Argument(None, help="Latitude, degrees North."),
890
+ radius: float = typer.Option(
891
+ 1.0, "--radius", "-r", help="Half-box size in degrees around the point."
892
+ ),
893
+ instrument: str = typer.Option(
894
+ None, "--instrument", help="Restrict to this instrument LID."
895
+ ),
896
+ count_only: bool = typer.Option(
897
+ False, "--count", help="Print only the number of matching products."
898
+ ),
899
+ limit: int = typer.Option(20, "--limit", "-n", help="Max products to list."),
900
+ ):
901
+ """What PDS data exists at a coordinate on a body.
902
+
903
+ Spatial search of the NASA PDS registry by a bounding box around a point.
904
+ BODY is a planet name (mapped to its target LID) or a full target LID.
905
+
906
+ Examples:
907
+ plp search at Mars 77.4 18.4 # ~Jezero crater
908
+ plp search at Mars 77.4 18.4 -r 0.5 --count
909
+
910
+ Note: only products whose archive populated ``cart:Bounding_Coordinates`` can
911
+ match — common for derived/calibrated products, often missing for raw/EDR.
912
+ """
913
+ if body is None or lon is None or lat is None:
914
+ typer.echo(ctx.get_help())
915
+ raise typer.Exit()
916
+
917
+ from planetarypy import search as _search
918
+
919
+ target = body if body.startswith("urn:") else (
920
+ f"urn:nasa:pds:context:target:planet.{body.lower()}"
921
+ )
922
+ bbox = _search.bbox_from_point(lon, lat, radius)
923
+ kw = dict(target=target, instrument=instrument, observationals=True, bbox=bbox)
924
+
925
+ n = _search.count(**kw)
926
+ typer.echo(
927
+ f"{n} product(s) overlap {lat}°N {lon}°E ±{radius}° on "
928
+ f"{body} (cart-indexed products only)", err=True,
929
+ )
930
+ if count_only:
931
+ typer.echo(n)
932
+ return
933
+ if n == 0:
934
+ return
935
+
936
+ from rich.console import Console
937
+ from rich.table import Table
938
+
939
+ df = _search.search_products(
940
+ **kw,
941
+ fields=["ref_lid_instrument", "pds:Time_Coordinates.pds:start_date_time"],
942
+ limit=limit,
943
+ )
944
+ table = Table(
945
+ title=f"{body}: data at {lat}°N {lon}°E (showing {len(df)} of {n})",
946
+ header_style="bold magenta", pad_edge=False,
947
+ )
948
+ table.add_column("lidvid", overflow="fold")
949
+ table.add_column("instrument", overflow="fold")
950
+ table.add_column("start_time")
951
+ for lidvid, row in df.iterrows():
952
+ instr = row.get("ref_lid_instrument")
953
+ instr = str(instr).rsplit(":", 1)[-1] if instr is not None else ""
954
+ start = row.get("pds:Time_Coordinates.pds:start_date_time") or ""
955
+ table.add_row(str(lidvid), instr, str(start))
956
+ Console().print(table)
957
+
958
+
879
959
  @search_app.command("fetch", rich_help_panel=_PANEL_FETCH)
880
960
  def search_fetch_cmd(
881
961
  ctx: typer.Context,
@@ -0,0 +1,432 @@
1
+ """Body-namespaced access to remote (non-PDS) reference rasters.
2
+
3
+ First slice of the design in ``Plans/datasets_subpackage_design.qmd``: a
4
+ baked-in registry of remote data products, namespaced **by body**, with windowed
5
+ ``/vsicurl/`` reads of cloud-optimised GeoTIFFs by lon/lat box — no full
6
+ download. Raster + vsicurl only for now; the remote-refreshed registry, download
7
+ mode, and ``plp datasets`` CLI from the design doc are deferred.
8
+
9
+ from planetarypy import datasets
10
+
11
+ datasets.bodies() # ['mars']
12
+ datasets.mars.hrsc_level3 # a RemoteRaster
13
+ da = datasets.mars.hrsc_level3.read_window(lon=0, lat=0, size=1.0) # DataArray
14
+ datasets.mars.hrsc_level3.read_window(0, 0, 1.0, out="patch.tif") # + GeoTIFF
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Optional, Union
22
+
23
+ __all__ = [
24
+ "RemoteRaster", "StacCollection", "StacItem",
25
+ "list_datasets", "bodies", "read_window", "read_bbox", "stac_search",
26
+ ]
27
+
28
+ # GDAL options that make /vsicurl/ COG reads fast (no directory listing, only
29
+ # fetch the byte ranges the window needs).
30
+ _GDAL_ENV = {
31
+ "GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
32
+ "CPL_VSIL_CURL_ALLOWED_EXTENSIONS": ".tif",
33
+ "GDAL_HTTP_MAX_RETRY": "3",
34
+ "GDAL_HTTP_RETRY_DELAY": "1",
35
+ }
36
+
37
+
38
+ def _set_gdal_env() -> None:
39
+ import os
40
+
41
+ for k, v in _GDAL_ENV.items():
42
+ os.environ.setdefault(k, v)
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class RemoteRaster:
47
+ """A remote reference raster, accessed by streaming (``/vsicurl/``).
48
+
49
+ ``crs`` is informational metadata (the read uses the file's own CRS); the
50
+ other fields document provenance. See the design doc for the fuller
51
+ ``DatasetConfig`` this will grow into.
52
+ """
53
+
54
+ key: str # dotted: "dlr.mars.hrsc.level3_eqc"
55
+ short: str # body-namespace attribute, e.g. "hrsc_level3"
56
+ name: str
57
+ body: str # lower-case body name, e.g. "mars"
58
+ provider: str
59
+ url: str
60
+ crs: str # e.g. "IAU_2015:49910"
61
+ kind: str = "vsicurl"
62
+ nodata: "Optional[float]" = None
63
+ pixel_scale_m: "Optional[float]" = None
64
+ version: "Optional[str]" = None
65
+ homepage: "Optional[str]" = None
66
+ citation: "Optional[str]" = None
67
+
68
+ @property
69
+ def vsicurl(self) -> str:
70
+ """The GDAL ``/vsicurl/`` URL for streaming reads."""
71
+ return self.url if self.url.startswith("/vsicurl/") else f"/vsicurl/{self.url}"
72
+
73
+ def open(self):
74
+ """Open the remote COG as a ``rasterio`` dataset (streamed, not downloaded)."""
75
+ import rasterio
76
+
77
+ _set_gdal_env()
78
+ return rasterio.open(self.vsicurl)
79
+
80
+ def read_window(self, lon: float, lat: float, size: float = 1.0, *,
81
+ anchor: str = "center",
82
+ out: "Optional[Union[str, Path]]" = None):
83
+ """Read a ``size``-degree lon/lat box positioned at ``(lon, lat)``.
84
+
85
+ ``anchor`` says where ``(lon, lat)`` sits on the box:
86
+ ``"center"`` (default), or a corner — ``"lower-left"``/``"sw"``,
87
+ ``"upper-left"``/``"nw"``, ``"lower-right"``/``"se"``,
88
+ ``"upper-right"``/``"ne"`` (degree-square tiling conventions use the SW
89
+ corner; raster/array origin is the NW corner).
90
+
91
+ Returns a georeferenced :class:`xarray.DataArray` (rioxarray). If ``out``
92
+ is given, the window is also written to that GeoTIFF. Reads only the byte
93
+ ranges the window needs over HTTP — no full download.
94
+ """
95
+ return read_window(self, lon, lat, size, anchor=anchor, out=out)
96
+
97
+ def read_bbox(self, west: float, south: float, east: float, north: float, *,
98
+ out: "Optional[Union[str, Path]]" = None):
99
+ """Read an explicit lon/lat box (degrees). See :func:`read_bbox`."""
100
+ return read_bbox(self, west, south, east, north, out=out)
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class StacItem:
105
+ """One STAC item resolved to its COG asset — reads like a RemoteRaster."""
106
+
107
+ id: str
108
+ cog_url: str
109
+ collection: str
110
+ bbox: tuple = () # raw STAC bbox (USGS reports it in projected m)
111
+ datetime: "Optional[str]" = None
112
+ nodata: "Optional[float]" = None
113
+ short: str = "stac_item"
114
+
115
+ @property
116
+ def vsicurl(self) -> str:
117
+ u = self.cog_url
118
+ return u if u.startswith("/vsicurl/") else f"/vsicurl/{u}"
119
+
120
+ def read_window(self, lon, lat, size=1.0, *, anchor="center", out=None):
121
+ return read_window(self, lon, lat, size, anchor=anchor, out=out)
122
+
123
+ def read_bbox(self, west, south, east, north, *, out=None):
124
+ return read_bbox(self, west, south, east, north, out=out)
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class StacCollection:
129
+ """A STAC-backed dataset: query a collection by location → COG items.
130
+
131
+ Unlike :class:`RemoteRaster` (one fixed COG), a collection holds many COGs
132
+ (per quad / per observation); :meth:`search` / :meth:`at` resolve a location
133
+ to the matching :class:`StacItem` (s), which then read like any COG.
134
+ """
135
+
136
+ key: str
137
+ short: str
138
+ name: str
139
+ body: str
140
+ provider: str
141
+ stac_url: str # STAC API root, e.g. ".../api"
142
+ collection: str # STAC collection id
143
+ kind: str = "stac"
144
+ asset_key: "Optional[str]" = None # which asset to read; None = first COG asset
145
+ homepage: "Optional[str]" = None
146
+
147
+ def search(self, *, bbox=None, lon=None, lat=None, limit: int = 20):
148
+ """Items overlapping a bbox ``(west, south, east, north)`` deg, or a point."""
149
+ return stac_search(self, bbox=bbox, lon=lon, lat=lat, limit=limit)
150
+
151
+ def at(self, lon: float, lat: float, *, limit: int = 20):
152
+ """Items covering a lon/lat point."""
153
+ return stac_search(self, lon=lon, lat=lat, limit=limit)
154
+
155
+ def read_window(self, lon, lat, size=1.0, *, anchor="center", out=None):
156
+ """Convenience: read from the first item covering ``(lon, lat)``."""
157
+ items = self.at(lon, lat, limit=1)
158
+ if not items:
159
+ raise ValueError(
160
+ f"no {self.collection!r} item covers lon={lon}, lat={lat}"
161
+ )
162
+ return items[0].read_window(lon, lat, size, anchor=anchor, out=out)
163
+
164
+
165
+ def _pick_cog_asset(assets: dict, asset_key: "Optional[str]") -> "Optional[str]":
166
+ """Href of the COG asset: the named one, else the first GeoTIFF/COG asset."""
167
+ if asset_key and asset_key in assets:
168
+ return assets[asset_key].get("href")
169
+ for a in assets.values():
170
+ typ = str(a.get("type", "")).lower()
171
+ href = a.get("href", "")
172
+ if "cloud-optimized" in typ or "geotiff" in typ or href.lower().endswith(".tif"):
173
+ return href
174
+ return None
175
+
176
+
177
+ def stac_search(coll: "StacCollection", *, bbox=None, lon=None, lat=None,
178
+ limit: int = 20) -> "list[StacItem]":
179
+ """Query a STAC collection; return its COG-bearing items as StacItems.
180
+
181
+ ``bbox`` is ``(west, south, east, north)`` in degrees; or pass ``lon``/``lat``
182
+ for a point (expanded to a tiny box, since STAC rejects a zero-area bbox).
183
+ """
184
+ import requests
185
+
186
+ if bbox is None:
187
+ if lon is None or lat is None:
188
+ raise ValueError("provide bbox=(west, south, east, north) or lon= and lat=")
189
+ eps = 1e-4
190
+ bbox = (lon - eps, lat - eps, lon + eps, lat + eps)
191
+ params = {
192
+ "collections": coll.collection,
193
+ "bbox": ",".join(str(v) for v in bbox),
194
+ "limit": int(limit),
195
+ }
196
+ resp = requests.get(f"{coll.stac_url}/search", params=params, timeout=60)
197
+ resp.raise_for_status()
198
+ items: "list[StacItem]" = []
199
+ for f in resp.json().get("features", []):
200
+ href = _pick_cog_asset(f.get("assets", {}), coll.asset_key)
201
+ if not href:
202
+ continue
203
+ items.append(StacItem(
204
+ id=f.get("id"),
205
+ cog_url=href,
206
+ collection=coll.collection,
207
+ bbox=tuple(f.get("bbox", ()) or ()),
208
+ datetime=(f.get("properties", {}) or {}).get("datetime"),
209
+ short=coll.short,
210
+ ))
211
+ return items
212
+
213
+
214
+ # ── baked-in registry (the remote-refreshed TOML from the design doc is deferred) ──
215
+ _USGS_STAC = "https://stac.astrogeology.usgs.gov/api"
216
+
217
+ _REGISTRY: dict = {
218
+ r.key: r
219
+ for r in [
220
+ # ── single global COG (FU Berlin / DLR) ──
221
+ RemoteRaster(
222
+ key="dlr.mars.hrsc.level3_eqc",
223
+ short="hrsc_level3",
224
+ name="HRSC level-3 global mosaic (equirectangular, IAU 49910)",
225
+ body="mars",
226
+ provider="FU Berlin / DLR (HRSC)",
227
+ url="https://maps.planet.fu-berlin.de/level3/level3-iau-eqc.tif",
228
+ crs="IAU_2015:49910",
229
+ kind="vsicurl",
230
+ nodata=-32768.0,
231
+ pixel_scale_m=50.0,
232
+ homepage="https://maps.planet.fu-berlin.de/",
233
+ ),
234
+ # ── USGS Astrogeology STAC collections (astrogeo-ard COGs) ──
235
+ StacCollection(
236
+ key="usgs.mars.themis.controlled_mosaics",
237
+ short="themis_mosaics",
238
+ name="THEMIS controlled IR mosaics (per MC quad)",
239
+ body="mars",
240
+ provider="USGS Astrogeology",
241
+ stac_url=_USGS_STAC,
242
+ collection="mo_themis_controlled_mosaics",
243
+ homepage="https://stac.astrogeology.usgs.gov/",
244
+ ),
245
+ StacCollection(
246
+ key="usgs.mars.ctx.controlled_dtms",
247
+ short="ctx_dtms",
248
+ name="CTX controlled USGS DTMs",
249
+ body="mars",
250
+ provider="USGS Astrogeology",
251
+ stac_url=_USGS_STAC,
252
+ collection="mro_ctx_controlled_usgs_dtms",
253
+ asset_key="geoid_adjusted_dem",
254
+ homepage="https://stac.astrogeology.usgs.gov/",
255
+ ),
256
+ StacCollection(
257
+ key="usgs.moon.lola.dtms",
258
+ short="lola_dtms",
259
+ name="Lunar Orbiter Laser Altimeter DTMs",
260
+ body="moon",
261
+ provider="USGS Astrogeology",
262
+ stac_url=_USGS_STAC,
263
+ collection="lunar_orbiter_laser_altimeter",
264
+ homepage="https://stac.astrogeology.usgs.gov/",
265
+ ),
266
+ ]
267
+ }
268
+
269
+
270
+ def list_datasets(body: "Optional[str]" = None) -> "list[RemoteRaster]":
271
+ """Registered rasters, optionally filtered to one body (case-insensitive)."""
272
+ out = list(_REGISTRY.values())
273
+ if body is not None:
274
+ out = [r for r in out if r.body == body.lower()]
275
+ return out
276
+
277
+
278
+ def bodies() -> "list[str]":
279
+ """Sorted list of bodies that have at least one registered raster."""
280
+ return sorted({r.body for r in _REGISTRY.values()})
281
+
282
+
283
+ # anchor name (and aliases) -> internal code: where (lon, lat) sits on the box.
284
+ _ANCHOR_ALIASES = {
285
+ "center": "center", "centre": "center", "c": "center",
286
+ "lower-left": "ll", "lower left": "ll", "ll": "ll", "sw": "ll", "southwest": "ll",
287
+ "upper-left": "ul", "upper left": "ul", "ul": "ul", "nw": "ul", "northwest": "ul",
288
+ "lower-right": "lr", "lower right": "lr", "lr": "lr", "se": "lr", "southeast": "lr",
289
+ "upper-right": "ur", "upper right": "ur", "ur": "ur", "ne": "ur", "northeast": "ur",
290
+ }
291
+
292
+
293
+ def _box_lonlat(lon: float, lat: float, size: float, anchor: str):
294
+ """``(lon, lat, size, anchor)`` → geographic ``(west, south, east, north)``."""
295
+ a = _ANCHOR_ALIASES.get(str(anchor).strip().lower())
296
+ if a is None:
297
+ raise ValueError(
298
+ "anchor must be center / lower-left / upper-left / lower-right / "
299
+ f"upper-right (or sw/nw/se/ne); got {anchor!r}"
300
+ )
301
+ if a == "center":
302
+ h = size / 2.0
303
+ return (lon - h, lat - h, lon + h, lat + h)
304
+ if a == "ll": # (lon, lat) is the SW corner
305
+ return (lon, lat, lon + size, lat + size)
306
+ if a == "ul": # NW corner
307
+ return (lon, lat - size, lon + size, lat)
308
+ if a == "lr": # SE corner
309
+ return (lon - size, lat, lon, lat + size)
310
+ return (lon - size, lat - size, lon, lat) # "ur": NE corner
311
+
312
+
313
+ @dataclass(frozen=True)
314
+ class _UrlSource:
315
+ """Minimal readable source for a bare COG URL (no registry entry)."""
316
+
317
+ url: str
318
+ nodata: "Optional[float]" = None
319
+ short: str = "window"
320
+
321
+ @property
322
+ def vsicurl(self) -> str:
323
+ return self.url if self.url.startswith("/vsicurl/") else f"/vsicurl/{self.url}"
324
+
325
+
326
+ def _as_source(source):
327
+ """Resolve a RemoteRaster / StacItem / registry key / URL to a readable source."""
328
+ if hasattr(source, "vsicurl"):
329
+ return source
330
+ if isinstance(source, str):
331
+ if source in _REGISTRY and hasattr(_REGISTRY[source], "vsicurl"):
332
+ return _REGISTRY[source]
333
+ if source.startswith(("http://", "https://", "/vsicurl/")):
334
+ return _UrlSource(source)
335
+ raise KeyError(f"{source!r} is not a readable registry key or URL")
336
+ raise TypeError(f"cannot read from {type(source).__name__}")
337
+
338
+
339
+ def read_bbox(source, west: float, south: float, east: float, north: float, *,
340
+ out: "Optional[Union[str, Path]]" = None):
341
+ """Read an explicit lon/lat box (degrees) from a raster source.
342
+
343
+ ``source`` is a :class:`RemoteRaster`, a :class:`StacItem`, a registry key,
344
+ or a bare COG URL. The box is transformed into the file's own CRS via pyproj
345
+ (so this works for any body / projection, not just equirectangular), then
346
+ read with a single rasterio window. Returns a georeferenced
347
+ :class:`xarray.DataArray`; writes a GeoTIFF too when ``out`` is given. Reads
348
+ only the byte ranges the window needs.
349
+ """
350
+ import rasterio
351
+ import xarray as xr
352
+ from pyproj import CRS, Transformer
353
+ from rasterio.windows import from_bounds
354
+
355
+ import rioxarray # noqa: F401 (registers the .rio accessor)
356
+
357
+ src = _as_source(source)
358
+ _set_gdal_env()
359
+ with rasterio.open(src.vsicurl) as ds:
360
+ geodetic = CRS.from_user_input(ds.crs).geodetic_crs
361
+ tf = Transformer.from_crs(geodetic, ds.crs, always_xy=True)
362
+ xs, ys = tf.transform([west, east, west, east], [south, south, north, north])
363
+ win = from_bounds(
364
+ min(xs), min(ys), max(xs), max(ys), ds.transform
365
+ ).round_offsets().round_lengths()
366
+ data = ds.read(window=win)
367
+ transform = ds.window_transform(win)
368
+ crs = ds.crs
369
+ nodata = src.nodata if src.nodata is not None else ds.nodata
370
+
371
+ da = xr.DataArray(
372
+ data, dims=("band", "y", "x"),
373
+ coords={"band": list(range(1, data.shape[0] + 1))},
374
+ name=getattr(src, "short", "window"),
375
+ )
376
+ da.rio.write_crs(crs, inplace=True)
377
+ da.rio.write_transform(transform, inplace=True)
378
+ if nodata is not None:
379
+ da.rio.write_nodata(nodata, inplace=True)
380
+ if out is not None:
381
+ da.rio.to_raster(str(out))
382
+ return da
383
+
384
+
385
+ def read_window(source, lon: float, lat: float, size: float = 1.0, *,
386
+ anchor: str = "center", out: "Optional[Union[str, Path]]" = None):
387
+ """Read a ``size``-degree lon/lat box positioned at ``(lon, lat)``.
388
+
389
+ ``source`` is anything :func:`read_bbox` accepts (RemoteRaster, StacItem,
390
+ registry key, or COG URL). ``anchor`` places ``(lon, lat)`` on the box:
391
+ ``"center"`` (default) or a corner — ``"lower-left"``/``"sw"`` (degree-square
392
+ tiling convention), ``"upper-left"``/``"nw"`` (raster origin),
393
+ ``"lower-right"``/``"se"``, ``"upper-right"``/``"ne"``. Thin wrapper over
394
+ :func:`read_bbox`.
395
+ """
396
+ west, south, east, north = _box_lonlat(lon, lat, size, anchor)
397
+ return read_bbox(source, west, south, east, north, out=out)
398
+
399
+
400
+ class _BodyNamespace:
401
+ """``datasets.mars`` — exposes that body's rasters as attributes."""
402
+
403
+ def __init__(self, body: str, rasters: "list[RemoteRaster]"):
404
+ self._body = body
405
+ self._by_short = {r.short: r for r in rasters}
406
+
407
+ def __getattr__(self, name: str) -> RemoteRaster:
408
+ try:
409
+ return self._by_short[name]
410
+ except KeyError:
411
+ raise AttributeError(
412
+ f"{self._body!r} has no dataset {name!r}; "
413
+ f"available: {sorted(self._by_short)}"
414
+ ) from None
415
+
416
+ def __dir__(self):
417
+ return list(super().__dir__()) + list(self._by_short)
418
+
419
+ def __repr__(self):
420
+ return f"<datasets.{self._body}: {sorted(self._by_short)}>"
421
+
422
+
423
+ def __getattr__(name: str):
424
+ """``datasets.<body>`` → a body namespace (e.g. ``datasets.mars``)."""
425
+ rasters = [r for r in _REGISTRY.values() if r.body == name.lower()]
426
+ if rasters:
427
+ return _BodyNamespace(name.lower(), rasters)
428
+ raise AttributeError(f"module 'planetarypy.datasets' has no attribute {name!r}")
429
+
430
+
431
+ def __dir__():
432
+ return sorted(set(list(globals()) + bodies()))
@@ -398,6 +398,14 @@ try:
398
398
  except ImportError:
399
399
  pass # catalog not available
400
400
 
401
+ # Register the HiRISE meta-display handler with the PDS meta dispatcher
402
+ try:
403
+ from planetarypy.pds.meta_display import register_meta_handler
404
+ register_meta_handler("mro.hirise.edr", format_meta)
405
+ register_meta_handler("mro.hirise.rdr", format_meta)
406
+ except ImportError:
407
+ pass # pds.meta_display not available
408
+
401
409
 
402
410
  def _edr_base_url() -> URL:
403
411
  """Resolve EDR base URL from config."""
@@ -4,8 +4,14 @@ Provides common visualization helpers for planetary science:
4
4
  percentile stretching, grayscale image display, sun direction indicators.
5
5
  """
6
6
 
7
+ import warnings
8
+
7
9
  import numpy as np
8
10
 
11
+ # One-shot guard so a multi-panel figure doesn't emit the experimental
12
+ # warning once per subplot.
13
+ _SUN_INDICATOR_WARNED = False
14
+
9
15
 
10
16
  def percentile_stretch(image, lo=1, hi=99):
11
17
  """Compute display limits from percentiles, ignoring zeros and NaN.
@@ -76,27 +82,34 @@ def imshow_gray(image, stretch="1,99", title=None, ax=None, **imshow_kwargs):
76
82
 
77
83
  def add_sun_indicator(ax, sun_azimuth_deg, position="upper right",
78
84
  length=0.12, color="yellow", inner_color="orange"):
79
- """Add a sun direction indicator to an image plot.
85
+ """Add a sun direction indicator to an image plot. **Experimental.**
86
+
87
+ .. warning::
88
+
89
+ Experimental. The azimuth-convention handoff is not fully validated:
90
+ this function expects **clockwise-from-image-top** (the PDS
91
+ ``SUB_SOLAR_AZIMUTH`` convention for *unprojected* images), but
92
+ :meth:`planetarypy.spice.spicer.Spicer.solar_azimuth_at` returns
93
+ **clockwise-from-north** (geographic). They agree only when image-north
94
+ points up; otherwise you must rotate by the image's north azimuth before
95
+ passing the value here. Placement and appearance may change.
80
96
 
81
- The indicator shows a circle (sun) with a line pointing in the
82
- direction of illumination. The azimuth is measured clockwise from
83
- the top of the image.
97
+ The indicator is a small compass glyph (sun ball + arrow) drawn in a corner,
98
+ pointing toward the sun. It is rendered in **axes-fraction coordinates**, so
99
+ it neither rescales the image nor depends on the image's ``origin``.
84
100
 
85
101
  Parameters
86
102
  ----------
87
103
  ax : matplotlib.axes.Axes
88
104
  The axes containing the image.
89
105
  sun_azimuth_deg : float
90
- Solar azimuth in degrees, clockwise from image top.
91
- This matches the PDS `SUB_SOLAR_AZIMUTH` convention for
92
- unprojected images.
106
+ Solar azimuth in degrees, clockwise from image top (see warning).
93
107
  position : str
94
- Where to place the indicator: "upper right", "upper left",
95
- "lower right", "lower left".
108
+ "upper right", "upper left", "lower right", "lower left".
96
109
  length : float
97
- Line length as fraction of image size (default 0.12).
110
+ Arrow length as a fraction of the axes (default 0.12).
98
111
  color : str
99
- Outer circle and line color.
112
+ Outer circle and arrow color.
100
113
  inner_color : str
101
114
  Inner circle color (smaller, overlaid on outer).
102
115
 
@@ -104,56 +117,62 @@ def add_sun_indicator(ax, sun_azimuth_deg, position="upper right",
104
117
  -------
105
118
  ax : matplotlib.axes.Axes
106
119
  """
107
- xlim = ax.get_xlim()
108
- ylim = ax.get_ylim()
109
- # Image extent (works for both origin='upper' and data coords)
110
- width = abs(xlim[1] - xlim[0])
111
- height = abs(ylim[1] - ylim[0])
112
-
113
- # Position the indicator
114
- margin = 0.08
115
- positions = {
116
- "upper right": (xlim[0] + width * (1 - margin), min(ylim) + height * margin),
117
- "upper left": (xlim[0] + width * margin, min(ylim) + height * margin),
118
- "lower right": (xlim[0] + width * (1 - margin), min(ylim) + height * (1 - margin)),
119
- "lower left": (xlim[0] + width * margin, min(ylim) + height * (1 - margin)),
120
+ global _SUN_INDICATOR_WARNED
121
+ if not _SUN_INDICATOR_WARNED:
122
+ warnings.warn(
123
+ "add_sun_indicator is experimental: the azimuth convention "
124
+ "(clockwise-from-top vs Spicer's clockwise-from-north) is not fully "
125
+ "validated, and placement/appearance may change.",
126
+ UserWarning,
127
+ stacklevel=2,
128
+ )
129
+ _SUN_INDICATOR_WARNED = True
130
+
131
+ L = float(length)
132
+ # Inset the anchor by at least the arrow length so the glyph stays inside the
133
+ # axes for *any* azimuth. Axes-fraction coords (y up, independent of image
134
+ # origin); drawing here never touches the data limits, so the image is not
135
+ # rescaled — the bug the old data-coordinate corner placement had.
136
+ inset = L + 0.06
137
+ corners = {
138
+ "upper right": (1 - inset, 1 - inset),
139
+ "upper left": (inset, 1 - inset),
140
+ "lower right": (1 - inset, inset),
141
+ "lower left": (inset, inset),
120
142
  }
121
- cx, cy = positions.get(position, positions["upper right"])
122
-
123
- line_len = length * max(width, height)
124
- az_rad = np.radians(sun_azimuth_deg)
125
- # CW from top: dx = sin(az), dy = -cos(az) for origin='upper'
126
- dx = line_len * np.sin(az_rad)
127
- dy = -line_len * np.cos(az_rad)
143
+ cx, cy = corners.get(position, corners["upper right"])
128
144
 
129
- # Sun circle in the sun direction, arrow points outward toward sun
145
+ az = np.radians(sun_azimuth_deg)
146
+ # Clockwise from top in axes-fraction space (y increases upward):
147
+ # 0deg -> up (+y), 90deg -> right (+x).
148
+ dx = L * np.sin(az)
149
+ dy = L * np.cos(az)
130
150
  sx, sy = cx + dx, cy + dy
131
151
 
132
- # Shorten arrow slightly so the tip doesn't overlap the sun ball
133
- shrink = 0.08 * line_len
134
- norm = np.hypot(dx, dy)
135
- ax_dx = dx / norm * shrink
136
- ax_dy = dy / norm * shrink
152
+ # Stop the arrow just short of the sun ball.
153
+ shrink = 0.12 * L
154
+ norm = np.hypot(dx, dy) or 1.0
155
+ tipx, tipy = sx - dx / norm * shrink, sy - dy / norm * shrink
137
156
 
138
157
  ax.annotate("",
139
- xy=(sx - ax_dx, sy - ax_dy), # arrow tip (gap before sun ball)
140
- xytext=(cx, cy), # arrow tail (interior)
158
+ xy=(tipx, tipy), xytext=(cx, cy),
159
+ xycoords="axes fraction", textcoords="axes fraction",
141
160
  arrowprops=dict(arrowstyle="-|>", color=color, lw=2.5,
142
161
  mutation_scale=20),
143
- zorder=5)
144
-
145
- # Sun symbol just past the arrow tip
146
- ax.plot(sx, sy, "o", color=color, markersize=14, zorder=8)
147
- ax.plot(sx, sy, "o", color=inner_color, markersize=9, zorder=9)
148
-
162
+ zorder=5, annotation_clip=False)
163
+ ax.plot(sx, sy, "o", color=color, markersize=14, zorder=8,
164
+ transform=ax.transAxes, clip_on=False)
165
+ ax.plot(sx, sy, "o", color=inner_color, markersize=9, zorder=9,
166
+ transform=ax.transAxes, clip_on=False)
149
167
  return ax
150
168
 
151
169
 
152
170
  def imshow_with_sun(image, sun_azimuth_deg, title=None, ax=None,
153
171
  stretch="1,99", sun_position="upper right", **imshow_kwargs):
154
- """Display a grayscale image with a sun direction indicator.
172
+ """Display a grayscale image with a sun direction indicator. **Experimental.**
155
173
 
156
- Combines `imshow_gray` with `add_sun_indicator`.
174
+ Combines `imshow_gray` with `add_sun_indicator` (see that function's
175
+ experimental warning about the azimuth convention).
157
176
 
158
177
  Parameters
159
178
  ----------
@@ -27,6 +27,8 @@ if TYPE_CHECKING:
27
27
 
28
28
  __all__ = [
29
29
  "search_products",
30
+ "count",
31
+ "bbox_from_point",
30
32
  "get_product",
31
33
  "product_file_urls",
32
34
  "fetch_pds_product",
@@ -82,6 +84,7 @@ def _build_q(
82
84
  observationals: bool,
83
85
  lidvid: Optional[str],
84
86
  query: Optional[str],
87
+ bbox: "Optional[tuple[float, float, float, float]]" = None,
85
88
  ) -> Optional[str]:
86
89
  """Translate keyword filters into a PDS API ``q`` clause string.
87
90
 
@@ -115,11 +118,28 @@ def _build_q(
115
118
  if lidvid:
116
119
  field = "lidvid" if "::" in lidvid else "lid"
117
120
  clauses.append(f'{field} eq "{lidvid}"')
121
+ if bbox is not None:
122
+ west, south, east, north = bbox
123
+ c = "cart:Bounding_Coordinates.cart:"
124
+ # Footprint *overlaps* the query box (intersection), not strict
125
+ # containment: product.west<=qe and product.east>=qw and
126
+ # product.south<=qn and product.north>=qs. Only matches products whose
127
+ # archive populated the cart:Bounding_Coordinates fields.
128
+ clauses += [
129
+ f"{c}west_bounding_coordinate le {east}",
130
+ f"{c}east_bounding_coordinate ge {west}",
131
+ f"{c}south_bounding_coordinate le {north}",
132
+ f"{c}north_bounding_coordinate ge {south}",
133
+ ]
118
134
  if query:
119
135
  clauses.append(query)
120
136
  if not clauses:
121
137
  return None
122
- return " and ".join(clauses)
138
+ # The PDS registry API requires the whole q wrapped in outer parentheses:
139
+ # a bare ``A and B`` returns HTTP 400 (UnparsableQParamException), while
140
+ # ``(A and B)`` parses. (peppi does the same in its ResultSet before sending.)
141
+ # Wrapping a single clause is harmless.
142
+ return "(" + " and ".join(clauses) + ")"
123
143
 
124
144
 
125
145
  def _flatten(value: Any) -> Any:
@@ -151,6 +171,7 @@ def search_products(
151
171
  observationals: bool = False,
152
172
  lidvid: Optional[str] = None,
153
173
  query: Optional[str] = None,
174
+ bbox: "Optional[tuple[float, float, float, float]]" = None,
154
175
  fields: Optional[list[str]] = None,
155
176
  limit: int = 100,
156
177
  host: str = _DEFAULT_HOST,
@@ -176,6 +197,14 @@ def search_products(
176
197
  Raw PDS API query clause, AND-combined with the other filters — the
177
198
  escape hatch for anything the keyword filters don't cover (e.g.
178
199
  ``query='lid like "urn:nasa:pds:cassini_iss_saturn*"'``).
200
+ bbox : (west, south, east, north), optional
201
+ Spatial filter: keep products whose footprint **overlaps** this
202
+ longitude/latitude box (degrees), via the
203
+ ``cart:Bounding_Coordinates`` fields. Order matches shapely's
204
+ ``.bounds`` / GeoJSON bbox. Note: only products whose archive populated
205
+ those fields can match (often present for derived/calibrated products,
206
+ absent for many raw/EDR), and a product's bounding box can be degenerate
207
+ for polar/long-track or anti-meridian-crossing footprints.
179
208
  fields : list[str], optional
180
209
  Restrict the returned columns to these registry property names.
181
210
  limit : int
@@ -202,6 +231,7 @@ def search_products(
202
231
  observationals=observationals,
203
232
  lidvid=lidvid,
204
233
  query=query,
234
+ bbox=bbox,
205
235
  )
206
236
  resp = _api(host).product_list(q=q, fields=fields, limit=limit)
207
237
  rows = [
@@ -214,6 +244,61 @@ def search_products(
214
244
  return df
215
245
 
216
246
 
247
+ def count(
248
+ *,
249
+ target: Optional[str] = None,
250
+ instrument: Optional[str] = None,
251
+ instrument_host: Optional[str] = None,
252
+ investigation: Optional[str] = None,
253
+ processing_level: Optional[str] = None,
254
+ before: "Optional[Union[str, _dt.datetime]]" = None,
255
+ after: "Optional[Union[str, _dt.datetime]]" = None,
256
+ observationals: bool = False,
257
+ lidvid: Optional[str] = None,
258
+ query: Optional[str] = None,
259
+ bbox: "Optional[tuple[float, float, float, float]]" = None,
260
+ host: str = _DEFAULT_HOST,
261
+ ) -> int:
262
+ """Number of products matching the filters, without fetching any rows.
263
+
264
+ Takes the same filter keywords as :func:`search_products`; issues a single
265
+ ``limit=0`` request and reads the registry's total-hit count. Useful to size
266
+ a result set (which :func:`search_products` would otherwise truncate at
267
+ ``limit``) before deciding how to page through it.
268
+ """
269
+ q = _build_q(
270
+ target=target,
271
+ instrument=instrument,
272
+ instrument_host=instrument_host,
273
+ investigation=investigation,
274
+ processing_level=processing_level,
275
+ before=before,
276
+ after=after,
277
+ observationals=observationals,
278
+ lidvid=lidvid,
279
+ query=query,
280
+ bbox=bbox,
281
+ )
282
+ return int(_api(host).product_list(q=q, limit=0).summary.hits)
283
+
284
+
285
+ def bbox_from_point(
286
+ lon: float, lat: float, radius_deg: float
287
+ ) -> "tuple[float, float, float, float]":
288
+ """Build a ``(west, south, east, north)`` box of ``±radius_deg`` around a point.
289
+
290
+ Convenience for the ``bbox=`` filter of :func:`search_products` — e.g. "data
291
+ within 1° of Jezero". Latitude is clamped to ``[-90, 90]``; longitude is
292
+ returned as-is (no anti-meridian wrapping), so use small radii near ±180°.
293
+ """
294
+ return (
295
+ lon - radius_deg,
296
+ max(-90.0, lat - radius_deg),
297
+ lon + radius_deg,
298
+ min(90.0, lat + radius_deg),
299
+ )
300
+
301
+
217
302
  def get_product(lidvid: str, *, host: str = _DEFAULT_HOST) -> dict:
218
303
  """Return one product's registry properties by LIDVID (or LID).
219
304
 
@@ -20,6 +20,7 @@ __all__ = [
20
20
  "list_cached_kernels",
21
21
  ]
22
22
 
23
+ import re
23
24
  import zipfile
24
25
  from datetime import timedelta
25
26
  from io import BytesIO
@@ -516,18 +517,35 @@ class Subsetter:
516
517
  else self.save_location
517
518
  )
518
519
  savepath = basepath / self.metakernel_file
519
- with (
520
- open(savepath, "w") as outfile,
521
- self.z.open(self.metakernel_file) as infile,
522
- ):
523
- for line in infile:
524
- linestr = line.decode()
525
- if "'./data'" in linestr:
526
- linestr = linestr.replace("'./data'", f"'{savepath.parent}'")
527
- outfile.write(linestr)
520
+ text = self.z.read(self.metakernel_file).decode()
521
+ savepath.write_text(_repoint_path_values(text, str(savepath.parent)))
528
522
  return savepath
529
523
 
530
524
 
525
+ def _repoint_path_values(text: str, abspath: str) -> str:
526
+ """Rewrite a metakernel's ``PATH_VALUES`` to the local kernel directory.
527
+
528
+ NAIF archive metakernels use different relative conventions for
529
+ ``PATH_VALUES`` — most missions ship ``'./data'``, but some (e.g. Hayabusa2's
530
+ PDS4 archive) ship ``'..'``. Replace whatever single-quoted value the
531
+ ``PATH_VALUES = ( … )`` block holds with ``abspath``, leaving every other line
532
+ (notably ``KERNELS_TO_LOAD``'s ``$SYMBOL/…`` references) untouched. The old
533
+ code matched the ``'./data'`` literal only, so it silently no-opped on ``'..'``
534
+ and left an unresolvable path that broke ``furnsh``.
535
+ """
536
+ out = []
537
+ in_path_values = False
538
+ for line in text.splitlines(keepends=True):
539
+ if "PATH_VALUES" in line:
540
+ in_path_values = True
541
+ if in_path_values:
542
+ line = re.sub(r"'[^']*'", f"'{abspath}'", line)
543
+ if ")" in line:
544
+ in_path_values = False
545
+ out.append(line)
546
+ return "".join(out)
547
+
548
+
531
549
  def get_metakernel_and_files(
532
550
  mission: str, start: str, stop: str, save_location: str = None, quiet: bool = False
533
551
  ) -> str:
File without changes
File without changes
File without changes
File without changes