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.
- {planetarypy-0.76.2 → planetarypy-0.77.0}/CHANGELOG.md +22 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/PKG-INFO +1 -1
- {planetarypy-0.76.2 → planetarypy-0.77.0}/pyproject.toml +3 -2
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/__init__.py +1 -1
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/cli.py +80 -0
- planetarypy-0.77.0/src/planetarypy/datasets/__init__.py +432 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/instruments/mro/hirise.py +8 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/plotting.py +66 -47
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/search.py +86 -1
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/spice/archived_kernels.py +27 -9
- planetarypy-0.76.2/src/planetarypy/instruments/go/__init__.py +0 -0
- planetarypy-0.76.2/src/planetarypy/instruments/mro/__init__.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/.gitignore +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/AUTHORS.md +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/LICENSE +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/README.md +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/docs/README.md +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/__init__.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_index_resolver.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_mission_map.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_objects.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_parser.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_pattern_resolver.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_repo.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_resolver.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_schema.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_url_rewrite.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/catalog/_validation.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/config.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/constants/__init__.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/constants/_gm_jpl.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/constants/base.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/constants/iau2009.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/constants/iau2015.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/constants/nssdc/__init__.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/constants/nssdc/_loader.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/crs.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/data/__init__.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/datetime_format_converters.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/geo.py +0 -0
- {planetarypy-0.76.2/src/planetarypy/instruments → planetarypy-0.77.0/src/planetarypy/instruments/go}/__init__.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/instruments/go/ssi.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/instruments/mro/ctx/__init__.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/instruments/mro/ctx/ctx_calib.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/instruments/mro/ctx/ctx_edr.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/instruments/utils.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/io.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/isis/autoseed.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/__init__.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/dynamic_index.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/dynamic_url_handlers.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/index_fixes.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/index_labels.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/index_logging.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/index_main.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/meta_display.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/static_index.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/pds/utils.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/psa.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/spice/__init__.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/spice/_deps.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/spice/config.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/spice/generic_kernels.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/spice/pckernels.py +0 -0
- {planetarypy-0.76.2 → planetarypy-0.77.0}/src/planetarypy/spice/spicer.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
205
|
+
current_version = "0.77.0"
|
|
205
206
|
commit = true
|
|
206
207
|
tag = true
|
|
207
208
|
|
|
@@ -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
|
|
82
|
-
|
|
83
|
-
the
|
|
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
|
-
|
|
95
|
-
"lower right", "lower left".
|
|
108
|
+
"upper right", "upper left", "lower right", "lower left".
|
|
96
109
|
length : float
|
|
97
|
-
|
|
110
|
+
Arrow length as a fraction of the axes (default 0.12).
|
|
98
111
|
color : str
|
|
99
|
-
Outer circle and
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
#
|
|
133
|
-
shrink = 0.
|
|
134
|
-
norm = np.hypot(dx, dy)
|
|
135
|
-
|
|
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=(
|
|
140
|
-
|
|
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
|
-
|
|
146
|
-
ax.plot(sx, sy, "o", color=
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|