#!/usr/bin/env python3 """Downloads the LROC WAC Hapke-normalized 643 nm mosaic of the Moon and makes the renderer's Moon image from it. Source: LRO Camera Wide Angle Camera mosaic, 643 nm band, photometrically normalized by Hapke parameter maps to incidence 60 deg, emission 0 deg, phase 60 deg (Sato et al. 2014, JGR Planets 119, 1775; https://www.lroc.asu.edu/posts/801), 400 m per pixel (76 pixels per degree), equirectangular, 70 S to 70 N, as eight PDS tiles of 32-bit floats (product WAC_HAPKE_643NM_*, data set LRO-L-LROC-5-RDR-V1.0, Arizona State University). The tiles go to data/moon/source/ and are checked by sha256. They are joined into one mosaic with longitude -180 to 180 left to right and latitude +90 at the top, like the Earth image. Missing pixels, and the polar caps beyond 70 degrees that the mosaic does not cover (6 % of the surface), get the mosaic's mean, weighted by area. The result is resampled by area to 16384 x 8192, the largest texture lavapipe takes (666 m per pixel at the equator), and written as data/moon/wac-hapke-643.png: 16-bit gray, each value the normalized reflectance I/F times 100,000. With --shape, it instead prints the Moon's mean reflectance in each of the WAC's seven bands relative to 643 nm over the whole mosaic, weighted by area: it downloads every tile of every band in turn (56 files, 8 GB) and keeps none of them. That is how the spectrum in crates/resolved/src/moon.rs was found. Needs numpy and Pillow. Building the image takes about 20 s and 2.6 GB of memory once the tiles are downloaded (about a minute at 50 MB/s, with a pause between files). """ import hashlib import sys import time import urllib.error import urllib.request from pathlib import Path import numpy as np from PIL import Image BASE = ( "https://pds.mcp.nasa.gov/data/store/img/lunar_reconnaissance_orbiter/pds4/lroc/" "lro-l-lroc-5-rdr/LROLRC_2001/DATA/MDR/WAC_HAPKE/" ) PPD = 76 # pixels per degree WIDTH = 6840 # samples per tile: 90 degrees RECORD = WIDTH * 4 # bytes per line, and the label's one record # Tile, its western longitude (0-360 E), north or south, and sha256 of the file. TILES = [ ("E350N0450", 0, "N", "1612ded2849aa784be4b1d7d8b46d6778e342a7838f5f59cd55373ac7347f2ec"), ("E350N1350", 90, "N", "1be241a613ed2141bd7a7b62d3b050a9e7d20b4e15aaae92fb665558bcf23601"), ("E350N2250", 180, "N", "8aa0b7b69501e606e3fd4968d99787443fd629bf7a2682b0854a8caecb759a1e"), ("E350N3150", 270, "N", "a92b6253b062b4c7b2a8da6eb0c8bb844b6d97c1bc5a518177970046c8e8b20a"), ("E350S0450", 0, "S", "967eb2a5fcbc7c550b1e411f7cb843c1d8c29f53d1f069353ec18fadaa05ef60"), ("E350S1350", 90, "S", "dc5c6b86f3a4dc6c6e3eb6e4766371b75aa97482980766377d7a3abb6775ad4c"), ("E350S2250", 180, "S", "27f487579d0543daa630961b1922125d03fed685871d5088c1d4b4a15c5b8578"), ("E350S3150", 270, "S", "4aeea79efcb6402b012abf07e41cbba9ca8c2e9af83535de67d1b3345f22918c"), ] BANDS = [321, 360, 415, 566, 604, 643, 689] # Lines per tile: north tiles have one more, just above 70 N, which is dropped. LINES = {"N": 5321, "S": 5320} ROWS = 70 * PPD # 5320 lines from the equator to 70 degrees OUT_W, OUT_H = 16384, 8192 SCALE = 100_000 # stored value per unit I/F STRIP = 512 # rows or columns resampled at a time def url(band, tile): return f"{BASE}WAC_HAPKE_{band}NM_{tile}.IMG" def download(address): """The whole file at `address`. One request at a time, a pause between them, and when the server answers 429 (too many requests) the wait it asks for: the PDS server blocks a client for an hour after a burst of requests.""" time.sleep(5) for _ in range(6): try: with urllib.request.urlopen(address) as response: return response.read() except urllib.error.HTTPError as e: if e.code != 429: raise wait = int(e.headers.get("Retry-After", "600")) print(f" rate-limited; waiting {wait} s", flush=True) time.sleep(wait + 30) sys.exit(f"{address}: still rate-limited") def fetch(band, tile, sha, directory): path = directory / f"WAC_HAPKE_{band}NM_{tile}.IMG" if not path.exists(): print(f"fetching {path.name}", flush=True) part = path.with_suffix(".part") part.write_bytes(download(url(band, tile))) part.rename(path) digest = hashlib.sha256(path.read_bytes()).hexdigest() if sha is None: print(f" sha256 {digest} {path.name} (not pinned)") elif digest != sha: sys.exit(f"{path}: sha256 {digest}, expected {sha}") return path def open_tile(path, hemisphere): """The tile's 5320 lines from the equator outward, as rows from north to south, mapped from the file rather than read into memory.""" data = np.memmap(path, dtype=" 1 else t - i return total[i] + frac * image[i] edges = np.arange(size + 1) * (n / size) out = (integral(edges[1:]) - integral(edges[:-1])) * (size / n) return np.moveaxis(out.astype(np.float32), 0, axis) def build(root): """Works through the mosaic STRIP rows (or columns) at a time, so memory stays near the 0.9 GB of the half-resampled image instead of the many GB a whole-mosaic resampling in float64 takes.""" source = root / "source" source.mkdir(parents=True, exist_ok=True) # Columns from -180: the tiles starting at 180 E and 270 E come first. order = sorted(TILES, key=lambda t: (t[1] + 180) % 360) halves = [ ([open_tile(fetch(643, t, s, source), h) for t, w, h, s in order if h == hemisphere], row_weights(ROWS, hemisphere == "N")) for hemisphere in "NS" ] # The mean of the valid pixels, by area. total = weight = 0.0 missing = 0 for tiles, weights in halves: for start in range(0, ROWS, STRIP): rows = mosaic_rows(tiles, start, start + STRIP) w = weights[start:start + STRIP, None] valid = ~np.isnan(rows) total += float(np.nansum(rows * w)) weight += float((w * valid).sum()) missing += int((~valid).sum()) mean = total / weight pixels = 2 * ROWS * 4 * WIDTH print(f"mosaic {4 * WIDTH} x {2 * ROWS}: mean I/F {mean:.5f}, " f"{missing} missing pixels ({100 * missing / pixels:.4f} %)") # Missing pixels and the polar caps get the mean. Resampled across, then down. cap = 20 * PPD height = 2 * cap + 2 * ROWS # 90 N to 90 S print(f"resampling {4 * WIDTH} x {height} to {OUT_W} x {OUT_H}", flush=True) across = np.empty((height, OUT_W), dtype=np.float32) across[:cap] = mean across[height - cap:] = mean for k, (tiles, _) in enumerate(halves): for start in range(0, ROWS, STRIP): rows = mosaic_rows(tiles, start, start + STRIP) rows[np.isnan(rows)] = mean top = cap + k * ROWS + start across[top:top + len(rows)] = resample(rows, 1, OUT_W) values = np.empty((OUT_H, OUT_W), dtype=np.uint16) low, high = np.inf, -np.inf for start in range(0, OUT_W, STRIP): down = resample(across[:, start:start + STRIP], 0, OUT_H) low, high = min(low, float(down.min())), max(high, float(down.max())) values[:, start:start + STRIP] = np.clip(np.round(down * SCALE), 0, 65535) out = root / "wac-hapke-643.png" Image.fromarray(values).save(out) print(f"wrote {out}: I/F from {low:.4f} to {high:.4f}") def band_mean(band): """Area-weighted mean I/F of all eight tiles of `band`, each downloaded, summed and discarded in turn.""" total = weight = 0.0 for tile, w, hemisphere, s in TILES: print(f" {band} nm {tile}", flush=True) data = np.frombuffer(download(url(band, tile)), dtype="