lunar-toolkit 0.1.0.dev2__py3-none-any.whl

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 (55) hide show
  1. lunar_toolkit/__init__.py +6 -0
  2. lunar_toolkit/asp.py +432 -0
  3. lunar_toolkit/cli/__init__.py +88 -0
  4. lunar_toolkit/cli/_jobs.py +437 -0
  5. lunar_toolkit/cli/_status.py +368 -0
  6. lunar_toolkit/cli/_utils.py +128 -0
  7. lunar_toolkit/cli/app.py +205 -0
  8. lunar_toolkit/cli/build_db.py +130 -0
  9. lunar_toolkit/cli/download.py +111 -0
  10. lunar_toolkit/cli/kaguya.py +253 -0
  11. lunar_toolkit/cli/ode_search.py +144 -0
  12. lunar_toolkit/cli/pairs.py +236 -0
  13. lunar_toolkit/cli/process.py +138 -0
  14. lunar_toolkit/cli/query.py +70 -0
  15. lunar_toolkit/cli/reference.py +233 -0
  16. lunar_toolkit/cli/search.py +90 -0
  17. lunar_toolkit/cli/serve.py +706 -0
  18. lunar_toolkit/cli/sfs.py +344 -0
  19. lunar_toolkit/cli/sfs_report.py +148 -0
  20. lunar_toolkit/cli/shadowcam.py +246 -0
  21. lunar_toolkit/cli/stereo.py +85 -0
  22. lunar_toolkit/cli/workspace.py +84 -0
  23. lunar_toolkit/config.py +345 -0
  24. lunar_toolkit/constants.py +217 -0
  25. lunar_toolkit/csm.py +340 -0
  26. lunar_toolkit/downloader.py +447 -0
  27. lunar_toolkit/geometry.py +556 -0
  28. lunar_toolkit/isis.py +222 -0
  29. lunar_toolkit/kaguya/__init__.py +45 -0
  30. lunar_toolkit/kaguya/mi.py +274 -0
  31. lunar_toolkit/kaguya/tc.py +370 -0
  32. lunar_toolkit/lroc/__init__.py +6 -0
  33. lunar_toolkit/lroc/cumindex.py +1197 -0
  34. lunar_toolkit/lroc/nac.py +702 -0
  35. lunar_toolkit/observation.py +127 -0
  36. lunar_toolkit/ode.py +485 -0
  37. lunar_toolkit/provenance.py +312 -0
  38. lunar_toolkit/reference.py +1111 -0
  39. lunar_toolkit/selection.py +714 -0
  40. lunar_toolkit/sfs.py +1534 -0
  41. lunar_toolkit/sfs_products.py +676 -0
  42. lunar_toolkit/sfs_report.py +1328 -0
  43. lunar_toolkit/shadowcam/__init__.py +29 -0
  44. lunar_toolkit/shadowcam/sc.py +392 -0
  45. lunar_toolkit/static/index.html +3259 -0
  46. lunar_toolkit/stereo.py +519 -0
  47. lunar_toolkit/stereo_dem.py +145 -0
  48. lunar_toolkit/tools.py +264 -0
  49. lunar_toolkit/workspace.py +1173 -0
  50. lunar_toolkit-0.1.0.dev2.dist-info/METADATA +177 -0
  51. lunar_toolkit-0.1.0.dev2.dist-info/RECORD +55 -0
  52. lunar_toolkit-0.1.0.dev2.dist-info/WHEEL +5 -0
  53. lunar_toolkit-0.1.0.dev2.dist-info/entry_points.txt +2 -0
  54. lunar_toolkit-0.1.0.dev2.dist-info/licenses/LICENSE +28 -0
  55. lunar_toolkit-0.1.0.dev2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,714 @@
1
+ """Illumination-diverse image selection for shape-from-shading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass, field
7
+ from typing import TYPE_CHECKING, Any, TypeAlias
8
+
9
+ import numpy as np
10
+ import shapely
11
+
12
+ from .geometry import AoiLike, footprint_geometry
13
+ from .stereo import angle_between, aoi_plane, azimuth_difference, observation_footprints
14
+
15
+ if TYPE_CHECKING: # pragma: no cover
16
+ from collections.abc import Callable
17
+
18
+ import pandas as pd
19
+
20
+ Prefer: TypeAlias = "str | Callable[[pd.DataFrame], pd.Series]"
21
+
22
+
23
+ def sort_by_sun_azimuth(
24
+ obs_df: pd.DataFrame,
25
+ start: float = 0.0,
26
+ column: str = "sub_solar_azimuth",
27
+ ) -> pd.DataFrame:
28
+ """Sort rows by Sun azimuth, measured clockwise from *start* degrees.
29
+
30
+ Rows with an unknown azimuth go last. The index is preserved.
31
+ """
32
+ import pandas as pd
33
+
34
+ az = pd.to_numeric(obs_df[column], errors="coerce")
35
+ key = (az - start) % 360.0
36
+ return obs_df.assign(_k=key).sort_values("_k", kind="stable", na_position="last").drop(columns="_k")
37
+
38
+
39
+ def aoi_coverage(obs_df: pd.DataFrame, aoi: AoiLike) -> pd.Series:
40
+ """Fraction (0–1) of the AOI covered by each row's footprint.
41
+
42
+ *aoi* is ``{"point": (lat, lon), "radius_km": r}``,
43
+ ``{"lat": (s, n), "lon": (w, e)}`` or anything
44
+ :func:`lunar_toolkit.geometry.load_aoi` accepts.
45
+ """
46
+ import pandas as pd
47
+
48
+ geoms, target = _aoi_cover(obs_df, aoi)
49
+ area = target.area
50
+ vals = [g.intersection(target).area / area if g is not None and area > 0 else 0.0 for g in geoms]
51
+ return pd.Series(vals, index=obs_df.index, name="aoi_coverage", dtype=float)
52
+
53
+
54
+ def _aoi_cover(obs_df: pd.DataFrame, aoi: AoiLike) -> tuple[list, Any]:
55
+ """Per row the footprint in the AOI's tangent plane (``None`` if absent / disjoint), and the AOI."""
56
+ plane, target, centre, radius = aoi_plane(aoi)
57
+ rings, centres, radii = observation_footprints(obs_df)
58
+ near = angle_between(centres, centre[None, :]) < radii + radius + 1e-6
59
+ geoms = [
60
+ footprint_geometry(r, plane.ring) if r is not None and near[k] else None for k, r in enumerate(rings)
61
+ ]
62
+ return geoms, target
63
+
64
+
65
+ def _candidates(
66
+ obs_df: pd.DataFrame,
67
+ incidence: tuple[float, float] | None,
68
+ max_emission: float | None,
69
+ aoi: AoiLike | None,
70
+ min_aoi_coverage: float,
71
+ ) -> pd.DataFrame:
72
+ import pandas as pd
73
+
74
+ df = obs_df.copy()
75
+ df["sub_solar_azimuth"] = pd.to_numeric(df["sub_solar_azimuth"], errors="coerce")
76
+ df = df[df["sub_solar_azimuth"].notna()]
77
+ if incidence is not None:
78
+ inc = pd.to_numeric(df["incidence"], errors="coerce")
79
+ df = df[inc.between(min(incidence), max(incidence))]
80
+ if max_emission is not None:
81
+ emi = pd.to_numeric(df["emission"], errors="coerce")
82
+ df = df[~(emi > max_emission)]
83
+ if aoi is not None and not df.empty:
84
+ df["aoi_coverage"] = aoi_coverage(df, aoi)
85
+ df = df[(df["aoi_coverage"] > 0) & (df["aoi_coverage"] >= min_aoi_coverage)]
86
+ else:
87
+ df["aoi_coverage"] = np.nan
88
+ return df
89
+
90
+
91
+ def _quality(df: pd.DataFrame, prefer: Prefer) -> pd.Series:
92
+ """Selection score (higher is better); see :func:`select_illumination_diverse`."""
93
+ import pandas as pd
94
+
95
+ if callable(prefer):
96
+ return pd.Series(prefer(df), index=df.index, dtype=float)
97
+ ps = (
98
+ pd.to_numeric(df["pixel_scale"], errors="coerce")
99
+ if "pixel_scale" in df
100
+ else pd.Series(np.nan, df.index)
101
+ )
102
+ res = (ps.min() / ps).fillna(0.5) if ps.notna().any() else pd.Series(0.5, index=df.index)
103
+ cov = df["aoi_coverage"].fillna(1.0)
104
+ if prefer == "balanced":
105
+ return cov * np.sqrt(res)
106
+ if prefer == "coverage":
107
+ return cov + 1e-3 * res
108
+ if prefer == "resolution":
109
+ return res + 1e-3 * cov
110
+ raise ValueError(f"prefer must be 'balanced', 'coverage', 'resolution' or a callable, not {prefer!r}")
111
+
112
+
113
+ def _thin_to(df: pd.DataFrame, max_images: int) -> pd.DataFrame:
114
+ """Drop picks until *max_images* remain, always removing the lower-scored
115
+ image of the closest pair in azimuth (keeps the azimuth spread)."""
116
+ df = df.copy()
117
+ while len(df) > max_images:
118
+ az = df["sub_solar_azimuth"].to_numpy(float)
119
+ d = azimuth_difference(az[:, None], az[None, :])
120
+ np.fill_diagonal(d, np.inf)
121
+ a, b = np.unravel_index(np.argmin(d), d.shape)
122
+ sc = df["selection_score"].to_numpy(float)
123
+ drop = a if sc[a] < sc[b] or (sc[a] == sc[b] and a > b) else b
124
+ df = df.iloc[np.delete(np.arange(len(df)), drop)]
125
+ return df
126
+
127
+
128
+ def select_illumination_diverse(
129
+ obs_df: pd.DataFrame,
130
+ azimuth_bin_deg: float = 30.0,
131
+ incidence: tuple[float, float] | None = None,
132
+ per_bin: int = 1,
133
+ max_images: int | None = None,
134
+ prefer: Prefer = "balanced",
135
+ aoi: AoiLike | None = None,
136
+ min_aoi_coverage: float = 0.0,
137
+ incidence_bin_deg: float | None = None,
138
+ max_emission: float | None = None,
139
+ method: str = "bins",
140
+ azimuth_origin: float = 0.0,
141
+ ) -> pd.DataFrame:
142
+ """Choose images spanning diverse Sun azimuths (for SfS / PSR mapping).
143
+
144
+ Parameters
145
+ ----------
146
+ obs_df : DataFrame
147
+ Observations in the common schema.
148
+ azimuth_bin_deg : float
149
+ Bin width (``method="bins"``) or minimum separation
150
+ (``method="greedy"``) in sub-solar azimuth, degrees.
151
+ incidence : (min, max), optional
152
+ Keep only images with incidence in this range (e.g. ``(80, 95)`` at
153
+ the poles; SfS needs lit but low-Sun images).
154
+ per_bin : int
155
+ Images kept per bin. With ``method="greedy"``: at most this many
156
+ picks may lie within *azimuth_bin_deg* of each other.
157
+ max_images : int, optional
158
+ Cap on the result. Excess picks are removed by repeatedly dropping
159
+ the lower-scored image of the pair closest in azimuth, so the
160
+ azimuth spread is kept.
161
+ prefer : {"balanced", "coverage", "resolution"} or callable
162
+ How to rank images competing for a bin. With ``res`` = finest
163
+ ``pixel_scale`` among candidates / own ``pixel_scale`` (0.5 if
164
+ unknown) and ``cov`` = AOI coverage fraction (1 without *aoi*):
165
+ ``"balanced"`` = ``cov * sqrt(res)``; ``"coverage"`` ranks by
166
+ coverage then resolution; ``"resolution"`` by resolution then
167
+ coverage. A callable gets the candidate DataFrame and returns the
168
+ score (higher is better).
169
+ aoi : dict, str, Path or Aoi, optional
170
+ ``{"point": (lat, lon), "radius_km": r}``, ``{"lat": ..., "lon": ...}``
171
+ or anything :func:`lunar_toolkit.geometry.load_aoi` accepts; images
172
+ not overlapping it are dropped.
173
+ min_aoi_coverage : float
174
+ Minimum fraction of the AOI an image must cover (with *aoi*).
175
+ incidence_bin_deg : float, optional
176
+ Also bin by incidence (``method="bins"`` only), giving azimuth ×
177
+ incidence cells — useful when shadow length diversity matters too.
178
+ max_emission : float, optional
179
+ Drop images with larger emission angles (off-nadir views are
180
+ foreshortened and complicate SfS).
181
+ method : {"bins", "greedy"}
182
+ ``"bins"``: fixed bins ``[origin + k*w, origin + (k+1)*w)``, best
183
+ *per_bin* per bin (two picks in neighbouring bins may still be close
184
+ in azimuth). ``"greedy"``: candidates in descending score; accept
185
+ one unless *per_bin* accepted images already lie within
186
+ *azimuth_bin_deg* of it, so with ``per_bin=1`` all picks are at
187
+ least *azimuth_bin_deg* apart (the Bertone et al. criterion).
188
+ azimuth_origin : float
189
+ Start of the first azimuth bin, degrees.
190
+
191
+ Returns
192
+ -------
193
+ DataFrame
194
+ The selected rows (original index kept) sorted by
195
+ ``sub_solar_azimuth`` (see :func:`sort_by_sun_azimuth`), with extra
196
+ columns ``aoi_coverage`` (NaN without *aoi*), ``azimuth_bin`` (bin
197
+ start, deg; NaN for greedy), ``incidence_bin`` (if binned) and
198
+ ``selection_score``.
199
+
200
+ Notes
201
+ -----
202
+ This uses each image's single scene-centre Sun azimuth; for a large AOI
203
+ where per-pixel diversity matters, use :func:`select_for_coverage`.
204
+ """
205
+ import pandas as pd
206
+
207
+ if azimuth_bin_deg <= 0 or per_bin < 1:
208
+ raise ValueError("azimuth_bin_deg must be > 0 and per_bin >= 1")
209
+ df = _candidates(obs_df, incidence, max_emission, aoi, min_aoi_coverage)
210
+ if df.empty:
211
+ return df.assign(azimuth_bin=pd.Series(dtype=float), selection_score=pd.Series(dtype=float))
212
+ df["selection_score"] = _quality(df, prefer)
213
+ df = df.sort_values("selection_score", ascending=False, kind="stable")
214
+
215
+ if method == "bins":
216
+ rel = (df["sub_solar_azimuth"] - azimuth_origin) % 360.0
217
+ df["azimuth_bin"] = (np.floor(rel / azimuth_bin_deg) * azimuth_bin_deg + azimuth_origin) % 360.0
218
+ keys = ["azimuth_bin"]
219
+ if incidence_bin_deg:
220
+ inc = pd.to_numeric(df["incidence"], errors="coerce")
221
+ df["incidence_bin"] = np.floor(inc / incidence_bin_deg) * incidence_bin_deg
222
+ keys.append("incidence_bin")
223
+ picked = df.groupby(keys, sort=False, dropna=False).head(per_bin)
224
+ elif method == "greedy":
225
+ df["azimuth_bin"] = np.nan
226
+ accepted: list[float] = []
227
+ keep = []
228
+ for idx, a in zip(df.index, df["sub_solar_azimuth"].to_numpy(float)):
229
+ near = int(np.sum(azimuth_difference(np.array(accepted), a) < azimuth_bin_deg)) if accepted else 0
230
+ if near < per_bin:
231
+ accepted.append(a)
232
+ keep.append(idx)
233
+ picked = df.loc[keep]
234
+ else:
235
+ raise ValueError(f"method must be 'bins' or 'greedy', not {method!r}")
236
+
237
+ if max_images is not None and len(picked) > max_images:
238
+ picked = _thin_to(picked, max_images)
239
+ return sort_by_sun_azimuth(picked)
240
+
241
+
242
+ def _grid(target, grid_km: float | None, max_cells: int = 20000) -> tuple[np.ndarray, np.ndarray]:
243
+ """Cell centres ``(x, y)`` of a regular grid over *target* that fall inside it."""
244
+ x0, y0, x1, y1 = target.bounds
245
+ if grid_km is None:
246
+ grid_km = max(max(x1 - x0, y1 - y0) / 100.0, 1e-3)
247
+ nx = max(int(math.ceil((x1 - x0) / grid_km)), 1)
248
+ ny = max(int(math.ceil((y1 - y0) / grid_km)), 1)
249
+ if nx * ny > max_cells:
250
+ f = math.sqrt(nx * ny / max_cells)
251
+ nx, ny = max(int(nx / f), 1), max(int(ny / f), 1)
252
+ gx, gy = np.meshgrid(
253
+ x0 + (np.arange(nx) + 0.5) * (x1 - x0) / nx, y0 + (np.arange(ny) + 0.5) * (y1 - y0) / ny
254
+ )
255
+ gx, gy = gx.ravel(), gy.ravel()
256
+ inside = shapely.intersects_xy(target, gx, gy)
257
+ return gx[inside], gy[inside]
258
+
259
+
260
+ def select_for_coverage(
261
+ obs_df: pd.DataFrame,
262
+ aoi: AoiLike,
263
+ azimuth_sep_deg: float = 30.0,
264
+ views_per_cell: int = 3,
265
+ incidence: tuple[float, float] | None = None,
266
+ max_images: int | None = None,
267
+ prefer: Prefer = "balanced",
268
+ max_emission: float | None = None,
269
+ grid_km: float | None = None,
270
+ min_gain: int = 1,
271
+ ) -> pd.DataFrame:
272
+ """Greedy, per-location illumination-diverse selection over an AOI.
273
+
274
+ The AOI is sampled on a grid (``grid_km`` spacing; default ~100 cells
275
+ across). Each grid cell keeps the Sun azimuths of the selected images
276
+ covering it. A candidate *helps* a cell if it covers the cell, the cell
277
+ has fewer than *views_per_cell* views and the candidate's azimuth is at
278
+ least *azimuth_sep_deg* from every azimuth already there. At each step
279
+ the image maximising ``helped_cells * (0.5 + 0.5 * score)`` is taken
280
+ (score as in :func:`select_illumination_diverse`, normalised to 0–1),
281
+ until no image helps *min_gain* cells or *max_images* is reached.
282
+
283
+ Returns
284
+ -------
285
+ DataFrame
286
+ Selected rows sorted by ``sub_solar_azimuth`` with extra columns
287
+ ``aoi_coverage``, ``selection_score``, ``selection_order`` (0 = first
288
+ pick) and ``cells_gained`` (fraction of AOI cells the pick helped).
289
+ ``attrs["view_fractions"]`` maps a view count *k* to the fraction of
290
+ AOI cells with exactly *k* non-redundant azimuths
291
+ (``views_per_cell`` = fully served), and ``attrs["grid_km"]`` /
292
+ ``attrs["n_cells"]`` describe the grid.
293
+ """
294
+ import pandas as pd
295
+
296
+ df = _candidates(obs_df, incidence, max_emission, aoi, 0.0)
297
+ if df.empty:
298
+ out = df.assign(
299
+ selection_score=pd.Series(dtype=float),
300
+ selection_order=pd.Series(dtype=int),
301
+ cells_gained=pd.Series(dtype=float),
302
+ )
303
+ out.attrs["view_fractions"] = {}
304
+ return out
305
+ score = _quality(df, prefer).to_numpy(float)
306
+ smax = np.nanmax(score) if np.isfinite(score).any() else 1.0
307
+ norm = np.nan_to_num(score / (smax or 1.0), nan=0.0)
308
+ df["selection_score"] = score
309
+
310
+ geoms, target = _aoi_cover(df, aoi)
311
+ cx, cy = _grid(target, grid_km)
312
+ if not len(cx):
313
+ raise ValueError("AOI grid is empty; pass a smaller grid_km")
314
+ cover = np.stack(
315
+ [shapely.intersects_xy(g, cx, cy) if g is not None else np.zeros(len(cx), bool) for g in geoms]
316
+ )
317
+ n_cells = len(cx)
318
+ az = df["sub_solar_azimuth"].to_numpy(float)
319
+ views = np.full((n_cells, views_per_cell), np.nan)
320
+ count = np.zeros(n_cells, int)
321
+ available = np.ones(len(df), bool)
322
+ order: list[int] = []
323
+ gained: list[float] = []
324
+ while available.any() and (max_images is None or len(order) < max_images):
325
+ best, best_val, best_mask = -1, 0.0, None
326
+ open_cells = count < views_per_cell
327
+ for k in np.flatnonzero(available):
328
+ m = cover[k] & open_cells
329
+ if not m.any():
330
+ continue
331
+ d = azimuth_difference(views[m], az[k])
332
+ ok = np.all(np.isnan(d) | (d >= azimuth_sep_deg), axis=1)
333
+ g = int(ok.sum())
334
+ val = g * (0.5 + 0.5 * norm[k])
335
+ if g >= min_gain and val > best_val:
336
+ best, best_val = int(k), val
337
+ best_mask = np.flatnonzero(m)[ok]
338
+ if best_mask is None:
339
+ break
340
+ views[best_mask, count[best_mask]] = az[best]
341
+ count[best_mask] += 1
342
+ available[best] = False
343
+ order.append(best)
344
+ gained.append(len(best_mask) / n_cells)
345
+
346
+ picked = df.iloc[order].copy()
347
+ picked["selection_order"] = np.arange(len(order))
348
+ picked["cells_gained"] = gained
349
+ out = sort_by_sun_azimuth(picked)
350
+ out.attrs["view_fractions"] = {int(k): float(np.mean(count == k)) for k in range(views_per_cell + 1)}
351
+ out.attrs["grid_km"] = float(np.sqrt(target.area / n_cells))
352
+ out.attrs["n_cells"] = int(n_cells)
353
+ return out
354
+
355
+
356
+ def selection_distances(
357
+ coverage: np.ndarray,
358
+ azimuth: np.ndarray,
359
+ weights: tuple[float, float] = (1.0, 1.0),
360
+ chunk: int = 256,
361
+ ) -> np.ndarray:
362
+ """Image-to-image distance of Bertone et al. (2026, sec. 2) for k-medoids selection.
363
+
364
+ ``d_ij = sqrt((w_a * a_ij)**2 + (w_c * c_ij)**2)`` with ``a_ij`` the
365
+ circular Sun azimuth (sub-solar longitude) difference and ``c_ij`` the
366
+ fraction of grid cells covered by exactly one of the two images, each
367
+ divided by its largest value over all pairs (so both span 0-1).
368
+
369
+ Parameters
370
+ ----------
371
+ coverage : (n_images, n_cells) bool array
372
+ Per-image coverage (or lit) masks on a common grid.
373
+ azimuth : (n_images,) array
374
+ Sun azimuths, degrees (finite).
375
+ weights : (w_a, w_c)
376
+ Weights of the azimuth and coverage terms.
377
+ chunk : int
378
+ Rows per block of the coverage products (memory).
379
+
380
+ Returns
381
+ -------
382
+ (n_images, n_images) float array
383
+ """
384
+ cov = np.asarray(coverage, bool)
385
+ az = np.asarray(azimuth, float)
386
+ n, cells = cov.shape
387
+ a = azimuth_difference(az[:, None], az[None, :])
388
+ f = cov.astype(np.float32)
389
+ counts = f.sum(axis=1)
390
+ c = np.empty((n, n))
391
+ for i in range(0, n, chunk):
392
+ inter = f[i : i + chunk] @ f.T
393
+ c[i : i + chunk] = (counts[i : i + chunk, None] + counts[None, :] - 2.0 * inter) / max(cells, 1)
394
+ np.fill_diagonal(c, 0.0)
395
+ a = a / np.max(a) if np.max(a) > 0 else a
396
+ c = c / np.max(c) if np.max(c) > 0 else c
397
+ return np.hypot(weights[0] * a, weights[1] * c)
398
+
399
+
400
+ def _build(d: np.ndarray, k: int) -> np.ndarray:
401
+ """PAM BUILD: the most central object, then each object that lowers the total distance most."""
402
+ med = [int(np.argmin(d.sum(axis=1)))]
403
+ near = d[:, med[0]].copy()
404
+ for _ in range(1, k):
405
+ gain = np.clip(near[:, None] - d, 0.0, None).sum(axis=0)
406
+ gain[med] = -1.0
407
+ c = int(np.argmax(gain))
408
+ med.append(c)
409
+ near = np.minimum(near, d[:, c])
410
+ return np.sort(med)
411
+
412
+
413
+ def kmedoids(
414
+ dist: np.ndarray,
415
+ k: int,
416
+ init: str = "build",
417
+ seed: int | None = 0,
418
+ n_init: int = 1,
419
+ max_iter: int = 100,
420
+ ) -> tuple[np.ndarray, np.ndarray]:
421
+ """Alternating k-medoids (Park & Jun 2009) on a precomputed distance matrix.
422
+
423
+ Assign every object to its nearest medoid, move each medoid to the
424
+ member with the smallest total distance to its cluster, repeat until the
425
+ medoids stop changing.
426
+
427
+ Parameters
428
+ ----------
429
+ dist : (n, n) array
430
+ Symmetric distances.
431
+ k : int
432
+ Number of clusters (all objects are medoids when ``k >= n``).
433
+ init : {"build", "park_jun", "random"}
434
+ ``"build"``: the PAM BUILD step (most central object, then greedy
435
+ cost reduction; deterministic and robust to duplicate images);
436
+ ``"park_jun"``: the *k* objects with the smallest
437
+ ``v_j = sum_i d_ij / sum_l d_il`` (Park & Jun's own start, which
438
+ favours central objects); ``"random"``: *k* distinct objects drawn
439
+ with *seed*, best of *n_init* runs.
440
+ seed : int, optional
441
+ Random generator seed (``init="random"``; ties are always broken by
442
+ the lowest index).
443
+ n_init : int
444
+ Random restarts (``init="random"``).
445
+ max_iter : int
446
+ Iteration cap.
447
+
448
+ Returns
449
+ -------
450
+ (medoids, labels)
451
+ Sorted medoid indices and each object's cluster (index into
452
+ *medoids*).
453
+ """
454
+ d = np.asarray(dist, float)
455
+ n = len(d)
456
+ if n == 0 or k <= 0:
457
+ return np.empty(0, int), np.full(n, -1)
458
+ if k >= n:
459
+ return np.arange(n), np.arange(n)
460
+ if init == "build":
461
+ starts = [_build(d, k)]
462
+ elif init == "park_jun":
463
+ row = d.sum(axis=1)
464
+ v = (d / np.where(row > 0, row, 1.0)[:, None]).sum(axis=0)
465
+ starts = [np.sort(np.argsort(v, kind="stable")[:k])]
466
+ elif init == "random":
467
+ rng = np.random.default_rng(seed)
468
+ starts = [np.sort(rng.choice(n, k, replace=False)) for _ in range(max(1, n_init))]
469
+ else:
470
+ raise ValueError(f"init must be 'build', 'park_jun' or 'random', not {init!r}")
471
+ best: tuple[float, np.ndarray, np.ndarray] | None = None
472
+ for med in starts:
473
+ for _ in range(max_iter):
474
+ labels = np.argmin(d[:, med], axis=1)
475
+ labels[med] = np.arange(k) # duplicates of a medoid stay in their own cluster
476
+ new = med.copy()
477
+ for j in range(k):
478
+ members = np.flatnonzero(labels == j)
479
+ new[j] = members[np.argmin(d[np.ix_(members, members)].sum(axis=1))]
480
+ new = np.sort(new)
481
+ if np.array_equal(new, med):
482
+ break
483
+ med = new
484
+ labels = np.argmin(d[:, med], axis=1)
485
+ labels[med] = np.arange(k)
486
+ cost = float(d[np.arange(n), med[labels]].sum())
487
+ if best is None or cost < best[0] - 1e-12:
488
+ best = (cost, med, labels)
489
+ assert best is not None
490
+ return best[1], best[2]
491
+
492
+
493
+ @dataclass
494
+ class KMedoidsSelection:
495
+ """Result of :func:`select_kmedoids` (indices refer to the input rows).
496
+
497
+ Attributes
498
+ ----------
499
+ chosen : list of int
500
+ Selected images in pick order: medoids first, then the coverage and
501
+ illumination refinements.
502
+ stage : list of str
503
+ Per entry of *chosen*: ``"medoid"``, ``"coverage"`` or ``"illumination"``.
504
+ medoids, labels : array
505
+ The k-medoids clustering (``labels[i]`` indexes *medoids*; -1 for
506
+ images without an azimuth).
507
+ directions : (n_cells,) int array
508
+ Distinct illumination directions reached per cell (capped at *views*).
509
+ uncovered, poorly_lit : float
510
+ Fractions of the potentially lit cells with no selected image, and
511
+ with fewer than *views* directions.
512
+ """
513
+
514
+ chosen: list[int]
515
+ stage: list[str]
516
+ medoids: np.ndarray
517
+ labels: np.ndarray
518
+ directions: np.ndarray
519
+ uncovered: float
520
+ poorly_lit: float
521
+ history: list[dict[str, Any]] = field(default_factory=list)
522
+
523
+
524
+ def select_kmedoids(
525
+ lit: np.ndarray,
526
+ azimuth: np.ndarray,
527
+ n_clusters: int = 30,
528
+ *,
529
+ coverage: np.ndarray | None = None,
530
+ views: int = 3,
531
+ azimuth_sep: float = 30.0,
532
+ max_uncovered: float = 0.005,
533
+ max_poorly_lit: float = 0.01,
534
+ max_images: int | None = None,
535
+ weights: tuple[float, float] = (1.0, 1.0),
536
+ init: str = "build",
537
+ seed: int | None = 0,
538
+ n_init: int = 1,
539
+ ) -> KMedoidsSelection:
540
+ """k-medoids image selection with coverage and illumination refinement (Bertone et al. 2026, sec. 2).
541
+
542
+ 1. Cluster the images with :func:`kmedoids` on
543
+ :func:`selection_distances` (Sun azimuth and coverage differences)
544
+ and take the *n_clusters* medoids.
545
+ 2. Add the image lighting the most still-uncovered cells until fewer
546
+ than *max_uncovered* of the potentially lit cells (lit in any image)
547
+ are uncovered.
548
+ 3. Add the image giving the most cells a new direction (Sun azimuth at
549
+ least *azimuth_sep* from the cell's directions so far, up to *views*)
550
+ until fewer than *max_poorly_lit* of the potentially lit cells have
551
+ fewer than *views* directions.
552
+
553
+ Steps 2-3 stop early when no image helps or *max_images* is reached;
554
+ ties go to the lowest index, so the result is deterministic (for
555
+ ``init="random"`` given *seed*).
556
+
557
+ Parameters
558
+ ----------
559
+ lit : (n_images, n_cells) bool array
560
+ Lit (valid and above the shadow threshold) cells per image on a
561
+ common grid, e.g. ``SfsPipeline._lit_masks()``.
562
+ azimuth : (n_images,) array
563
+ Sun azimuths in degrees; images with NaN are never selected.
564
+ n_clusters : int
565
+ Medoids (the paper's ~30 user-defined images).
566
+ coverage : (n_images, n_cells) bool array, optional
567
+ Footprint masks for the distance (default *lit*).
568
+ views, azimuth_sep : int, float
569
+ Directions wanted per cell and their minimum separation (3, 30 deg).
570
+ max_uncovered, max_poorly_lit : float
571
+ Stopping fractions of steps 2 and 3 (0.5 % and 1 %).
572
+ max_images : int, optional
573
+ Cap on the total (medoids are truncated to it first).
574
+ weights, init, seed, n_init
575
+ See :func:`selection_distances` and :func:`kmedoids`.
576
+ """
577
+ lit = np.asarray(lit, bool)
578
+ az = np.asarray(azimuth, float)
579
+ n, cells = lit.shape
580
+ cov = lit if coverage is None else np.asarray(coverage, bool)
581
+ valid = np.flatnonzero(np.isfinite(az))
582
+ labels = np.full(n, -1)
583
+ if len(valid):
584
+ med, lab = kmedoids(
585
+ selection_distances(cov[valid], az[valid], weights), n_clusters, init, seed, n_init
586
+ )
587
+ medoids = valid[med]
588
+ labels[valid] = lab
589
+ else:
590
+ medoids = np.empty(0, int)
591
+ cap = n if max_images is None else max(0, max_images)
592
+ chosen = [int(i) for i in medoids[:cap]]
593
+ stage = ["medoid"] * len(chosen)
594
+
595
+ potential = lit[valid].any(axis=0) if len(valid) else np.zeros(cells, bool)
596
+ n_pot = max(int(potential.sum()), 1)
597
+ dirs = np.full((cells, views), np.nan)
598
+ count = np.zeros(cells, int)
599
+
600
+ def add(k: int) -> None:
601
+ idx = np.flatnonzero(lit[k] & (count < views))
602
+ if len(idx):
603
+ d = azimuth_difference(dirs[idx], az[k])
604
+ idx = idx[np.all(np.isnan(d) | (d >= azimuth_sep), axis=1)]
605
+ dirs[idx, count[idx]] = az[k]
606
+ count[idx] += 1
607
+
608
+ for k in chosen:
609
+ add(k)
610
+ covered = lit[chosen].any(axis=0) if chosen else np.zeros(cells, bool)
611
+ available = np.zeros(n, bool)
612
+ available[valid] = True
613
+ available[chosen] = False
614
+ history: list[dict[str, Any]] = []
615
+
616
+ def fractions() -> tuple[float, float]:
617
+ return (
618
+ float(np.sum(potential & ~covered) / n_pot),
619
+ float(np.sum(potential & (count < views)) / n_pot),
620
+ )
621
+
622
+ while len(chosen) < cap and fractions()[0] >= max_uncovered and available.any():
623
+ cand = np.flatnonzero(available)
624
+ gain = (lit[cand] & ~covered & potential).sum(axis=1)
625
+ if gain.max() <= 0:
626
+ break
627
+ k = int(cand[np.argmax(gain)])
628
+ chosen.append(k)
629
+ stage.append("coverage")
630
+ covered |= lit[k]
631
+ available[k] = False
632
+ add(k)
633
+ history.append({"image": k, "stage": "coverage", "gain": int(gain.max())})
634
+
635
+ while len(chosen) < cap and fractions()[1] >= max_poorly_lit and available.any():
636
+ open_cells = potential & (count < views)
637
+ best, best_gain = -1, 0
638
+ for j in np.flatnonzero(available):
639
+ idx = np.flatnonzero(lit[j] & open_cells)
640
+ if len(idx) <= best_gain:
641
+ continue
642
+ d = azimuth_difference(dirs[idx], az[j])
643
+ g = int(np.all(np.isnan(d) | (d >= azimuth_sep), axis=1).sum())
644
+ if g > best_gain:
645
+ best, best_gain = int(j), g
646
+ if best < 0:
647
+ break
648
+ chosen.append(best)
649
+ stage.append("illumination")
650
+ covered |= lit[best]
651
+ available[best] = False
652
+ add(best)
653
+ history.append({"image": best, "stage": "illumination", "gain": best_gain})
654
+
655
+ unc, poor = fractions()
656
+ return KMedoidsSelection(chosen, stage, medoids, labels, count, unc, poor, history)
657
+
658
+
659
+ def coverage_matrix(
660
+ obs_df: pd.DataFrame, aoi: AoiLike, grid_km: float | None = None
661
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
662
+ """Footprint coverage of the AOI grid cells: ``(cover[n_images, n_cells], x, y)``.
663
+
664
+ The grid is the one :func:`select_for_coverage` uses (cell centres in
665
+ the AOI's tangent plane, km).
666
+ """
667
+ geoms, target = _aoi_cover(obs_df, aoi)
668
+ cx, cy = _grid(target, grid_km)
669
+ cover = (
670
+ np.stack(
671
+ [shapely.intersects_xy(g, cx, cy) if g is not None else np.zeros(len(cx), bool) for g in geoms]
672
+ )
673
+ if geoms
674
+ else np.zeros((0, len(cx)), bool)
675
+ )
676
+ return cover, cx, cy
677
+
678
+
679
+ def select_kmedoids_footprints(
680
+ obs_df: pd.DataFrame,
681
+ aoi: AoiLike,
682
+ n_clusters: int = 30,
683
+ incidence: tuple[float, float] | None = None,
684
+ max_emission: float | None = None,
685
+ grid_km: float | None = None,
686
+ **options: Any,
687
+ ) -> pd.DataFrame:
688
+ """:func:`select_kmedoids` on catalogue footprints over an AOI (footprint = lit, no shadows).
689
+
690
+ *options* go to :func:`select_kmedoids`. Returns the selected rows
691
+ sorted by ``sub_solar_azimuth`` with ``selection_order``,
692
+ ``selection_stage`` and ``cluster`` columns; ``attrs`` holds
693
+ ``uncovered``, ``poorly_lit`` and ``n_cells``.
694
+ """
695
+ import pandas as pd
696
+
697
+ df = _candidates(obs_df, incidence, max_emission, aoi, 0.0)
698
+ if df.empty:
699
+ out = df.assign(
700
+ selection_order=pd.Series(dtype=int),
701
+ selection_stage=pd.Series(dtype=str),
702
+ cluster=pd.Series(dtype=int),
703
+ )
704
+ out.attrs.update(uncovered=1.0, poorly_lit=1.0, n_cells=0)
705
+ return out
706
+ cover, cx, _ = coverage_matrix(df, aoi, grid_km)
707
+ res = select_kmedoids(cover, df["sub_solar_azimuth"].to_numpy(float), n_clusters, **options)
708
+ picked = df.iloc[res.chosen].copy()
709
+ picked["selection_order"] = np.arange(len(res.chosen))
710
+ picked["selection_stage"] = res.stage
711
+ picked["cluster"] = res.labels[res.chosen]
712
+ out = sort_by_sun_azimuth(picked)
713
+ out.attrs.update(uncovered=res.uncovered, poorly_lit=res.poorly_lit, n_cells=int(len(cx)))
714
+ return out