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,437 @@
1
+ """Background jobs for ``lunar serve`` / ``lunar app``: index builds and downloads with progress."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import itertools
6
+ import logging
7
+ import re
8
+ import threading
9
+ import time
10
+ from collections import deque
11
+ from collections.abc import Callable
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+ TERMINAL = ("done", "error", "cancelled")
20
+ MAX_IDS = 1000
21
+ _RELEASE_RE = re.compile(r"^LROLRC_\d{4}[A-Z]?$")
22
+
23
+
24
+ class Cancelled(Exception):
25
+ """Raised inside a job's thread once the job was cancelled."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class JobContext:
30
+ """Where jobs read and write: the Parquet data dir, image downloads and reference data."""
31
+
32
+ data_dir: Path
33
+ download_dir: Path
34
+ reference_dir: Path
35
+
36
+
37
+ class Job:
38
+ """One background task: status, progress (0-1), current file, bytes, rate, ETA and a log tail."""
39
+
40
+ def __init__(self, manager: JobManager, jid: str, kind: str, title: str, key: str, params: dict) -> None:
41
+ self._manager = manager
42
+ self.id, self.kind, self.title, self.key, self.params = jid, kind, title, key, params
43
+ self.status = "queued"
44
+ self.progress = 0.0
45
+ self.busy = False
46
+ self.cancellable = True
47
+ self.stage = ""
48
+ self.file = ""
49
+ self.file_index = self.file_count = 0
50
+ self.bytes_done = 0
51
+ self.bytes_total: int | None = None
52
+ self.rate: float | None = None
53
+ self.eta: float | None = None
54
+ self.error: str | None = None
55
+ self.result: Any = None
56
+ self.created = time.time()
57
+ self.started: float | None = None
58
+ self.finished: float | None = None
59
+ self.log: deque[str] = deque(maxlen=40)
60
+ self._span = (0.0, 1.0)
61
+ self._cancel = threading.Event()
62
+ self._last: tuple[float, int] | None = None
63
+
64
+ @property
65
+ def cancelled(self) -> bool:
66
+ return self._cancel.is_set()
67
+
68
+ def check(self) -> None:
69
+ """Raise :class:`Cancelled` if the job was cancelled."""
70
+ if self._cancel.is_set():
71
+ raise Cancelled()
72
+
73
+ def update(self, **fields: Any) -> None:
74
+ with self._manager._cond:
75
+ for k, v in fields.items():
76
+ setattr(self, k, v)
77
+ self._manager._changed()
78
+
79
+ def info(self, msg: str) -> None:
80
+ log.info("job %s: %s", self.id, msg)
81
+ with self._manager._cond:
82
+ self.log.append(f"{time.strftime('%H:%M:%S')} {msg}")
83
+ self._manager._changed()
84
+
85
+ def set_stage(self, stage: str, span: tuple[float, float] | None = None, busy: bool = False) -> None:
86
+ """Start a stage; with *span*, byte progress maps onto that part of the overall bar."""
87
+ self.check()
88
+ if span is not None:
89
+ self._span = span
90
+ self.info(stage)
91
+ self.update(stage=stage, busy=busy, progress=max(self.progress, self._span[0]))
92
+
93
+ def set_file(self, k: int, n: int, name: str) -> None:
94
+ """File *k* of *n* (1-based) starts; it covers ``[(k-1)/n, k/n]`` of the bar."""
95
+ self.check()
96
+ self._span = ((k - 1) / n, k / n)
97
+ self._last = None
98
+ self.update(
99
+ file=name, file_index=k, file_count=n, bytes_done=0, bytes_total=None, rate=None, eta=None,
100
+ progress=max(self.progress, self._span[0]), stage=f"File {k} of {n}",
101
+ ) # fmt: skip
102
+
103
+ def on_bytes(self, name: str, done: int, total: int | None) -> None:
104
+ """Download progress callback (:data:`lunar_toolkit.downloader.Progress`)."""
105
+ self.check()
106
+ if done < 0:
107
+ self.update(stage="Waiting: the host asked us to slow down", rate=None, eta=None)
108
+ return
109
+ now = time.monotonic()
110
+ rate = self.rate
111
+ if self._last is None or done < self._last[1]:
112
+ self._last = (now, done)
113
+ elif now - self._last[0] >= 0.5:
114
+ inst = (done - self._last[1]) / (now - self._last[0])
115
+ rate = inst if rate is None else 0.6 * rate + 0.4 * inst
116
+ self._last = (now, done)
117
+ lo, hi = self._span
118
+ frac = min(1.0, done / total) if total else 0.0
119
+ eta = (total - done) / rate if total and rate else None
120
+ self.update(
121
+ file=name, bytes_done=done, bytes_total=total, rate=rate, eta=eta,
122
+ progress=max(self.progress, lo + (hi - lo) * frac),
123
+ ) # fmt: skip
124
+
125
+ def to_dict(self, admin: bool = True) -> dict[str, Any]:
126
+ d: dict[str, Any] = {
127
+ k: getattr(self, k)
128
+ for k in (
129
+ "id", "kind", "title", "status", "progress", "busy", "cancellable", "stage", "file",
130
+ "file_index", "file_count", "bytes_done", "bytes_total", "rate", "eta", "created",
131
+ "started", "finished",
132
+ )
133
+ } # fmt: skip
134
+ if admin:
135
+ d.update(params=self.params, error=self.error, result=self.result, log=list(self.log))
136
+ else:
137
+ d.update(error="failed" if self.error else None)
138
+ return d
139
+
140
+
141
+ class JobManager:
142
+ """Runs jobs in a small thread pool and lets readers wait for any change (``version``)."""
143
+
144
+ def __init__(
145
+ self, workers: int = 2, keep: int = 50, on_finish: Callable[[Job], None] | None = None
146
+ ) -> None:
147
+ self._cond = threading.Condition()
148
+ self._on_finish = on_finish
149
+ self._jobs: dict[str, Job] = {}
150
+ self._pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="lunar-job")
151
+ self._ids = itertools.count(1)
152
+ self._keep = keep
153
+ self.version = 0
154
+ self.closed = False
155
+
156
+ def _changed(self) -> None:
157
+ self.version += 1
158
+ self._cond.notify_all()
159
+
160
+ def submit(self, kind: str, title: str, key: str, params: dict, func: Callable[[Job], Any]) -> Job:
161
+ """Queue *func(job)*; ValueError when a job with the same *key* is still active."""
162
+ with self._cond:
163
+ if self.closed:
164
+ raise RuntimeError("shutting down")
165
+ for other in self._jobs.values():
166
+ if other.key == key and other.status not in TERMINAL:
167
+ raise ValueError(f"{other.title} is already {other.status} (job {other.id})")
168
+ job = Job(self, str(next(self._ids)), kind, title, key, params)
169
+ self._jobs[job.id] = job
170
+ done = [j for j in self._jobs.values() if j.status in TERMINAL]
171
+ for old in done[: max(0, len(self._jobs) - self._keep)]:
172
+ del self._jobs[old.id]
173
+ self._changed()
174
+ self._pool.submit(self._run, job, func)
175
+ return job
176
+
177
+ def _run(self, job: Job, func: Callable[[Job], Any]) -> None:
178
+ if job.cancelled:
179
+ return
180
+ job.update(status="running", started=time.time())
181
+ try:
182
+ result = func(job)
183
+ except Cancelled:
184
+ job.info("Cancelled")
185
+ job.update(status="cancelled", finished=time.time(), busy=False)
186
+ except Exception as exc:
187
+ log.exception("job %s (%s) failed", job.id, job.kind)
188
+ job.info(f"Error: {exc}")
189
+ job.update(status="error", error=str(exc) or type(exc).__name__, finished=time.time(), busy=False)
190
+ else:
191
+ job.update(status="done", result=result, progress=1.0, finished=time.time(), busy=False, eta=None)
192
+ if self._on_finish:
193
+ self._on_finish(job)
194
+
195
+ def get(self, jid: str) -> Job | None:
196
+ with self._cond:
197
+ return self._jobs.get(jid)
198
+
199
+ def jobs(self) -> list[Job]:
200
+ with self._cond:
201
+ return list(self._jobs.values())
202
+
203
+ def cancel(self, jid: str) -> Job | None:
204
+ """Ask job *jid* to stop (a queued job is cancelled at once); None if unknown."""
205
+ with self._cond:
206
+ job = self._jobs.get(jid)
207
+ if job is None or job.status in TERMINAL:
208
+ return job
209
+ job._cancel.set()
210
+ if job.status == "queued":
211
+ job.status, job.finished = "cancelled", time.time()
212
+ self._changed()
213
+ return job
214
+
215
+ def wait(self, version: int, timeout: float) -> int:
216
+ """Block until :attr:`version` differs from *version* (or *timeout* / close); the new version."""
217
+ with self._cond:
218
+ self._cond.wait_for(lambda: self.version != version or self.closed, timeout)
219
+ return self.version
220
+
221
+ def close(self) -> None:
222
+ """Cancel everything and stop accepting jobs (running ones stop at their next check)."""
223
+ with self._cond:
224
+ self.closed = True
225
+ for job in self._jobs.values():
226
+ if job.status not in TERMINAL:
227
+ job._cancel.set()
228
+ self._changed()
229
+ self._pool.shutdown(wait=False, cancel_futures=True)
230
+
231
+
232
+ def _flag(p: dict, name: str) -> bool:
233
+ v = p.get(name, False)
234
+ if not isinstance(v, bool):
235
+ raise ValueError(f"{name} must be true or false")
236
+ return v
237
+
238
+
239
+ def _str(p: dict, name: str, default: str | None = None) -> str | None:
240
+ v = p.get(name, default)
241
+ if v is not None and not isinstance(v, str):
242
+ raise ValueError(f"{name} must be a string")
243
+ return v.strip() if v else default
244
+
245
+
246
+ def _strings(p: dict, name: str, limit: int = 100) -> list[str]:
247
+ v = p.get(name)
248
+ if isinstance(v, str):
249
+ v = v.split()
250
+ if not isinstance(v, list) or not all(isinstance(x, str) and x.strip() for x in v) or not v:
251
+ raise ValueError(f"{name} must be a non-empty list of strings")
252
+ if len(v) > limit:
253
+ raise ValueError(f"at most {limit} {name}")
254
+ return [x.strip() for x in v]
255
+
256
+
257
+ def _region(p: dict):
258
+ from .. import reference as ref
259
+
260
+ key = p.get("region")
261
+ if not isinstance(key, (str, int)) or isinstance(key, bool):
262
+ raise ValueError("region must be a region id or name")
263
+ try:
264
+ return ref.get_region(key)
265
+ except KeyError as exc:
266
+ raise ValueError(exc.args[0] if exc.args else str(exc)) from None
267
+
268
+
269
+ def _build_db(p: dict, ctx: JobContext) -> tuple[str, str, Callable[[Job], Any]]:
270
+ from ..constants import DATASETS
271
+
272
+ dataset = p.get("dataset")
273
+ if dataset not in DATASETS:
274
+ raise ValueError(f"dataset must be one of {', '.join(DATASETS)}")
275
+ update, force, volume_only = _flag(p, "update"), _flag(p, "force"), _flag(p, "volume_only")
276
+ release = _str(p, "release")
277
+ if release and not _RELEASE_RE.match(release.upper()):
278
+ raise ValueError("release must look like LROLRC_0066C")
279
+
280
+ def run(job: Job) -> dict[str, Any]:
281
+ from ..downloader import report_progress
282
+ from ..lroc.cumindex import CumIndex
283
+
284
+ ci = CumIndex(dataset, data_dir=ctx.data_dir)
285
+
286
+ def on_stage(stage: str) -> None:
287
+ if stage == "download":
288
+ job.set_stage("Downloading the index label and table", span=(0.0, 0.7))
289
+ else:
290
+ job.set_stage("Building the Parquet database (DuckDB)", busy=True)
291
+ job.update(cancellable=False)
292
+
293
+ with report_progress(job.on_bytes):
294
+ if update:
295
+ added = ci.update_db(on_stage=on_stage)
296
+ job.info(f"Appended {', '.join(added)}" if added else "Already up to date")
297
+ else:
298
+ ci.build_db(force=force, release=release, volume_only=volume_only, on_stage=on_stage)
299
+ info = ci.build_info() or {}
300
+ job.info(f"{info.get('rows', 0):,} rows, release {info.get('release')}")
301
+ return {"dataset": dataset, "rows": info.get("rows"), "release": info.get("release")}
302
+
303
+ verb = "Update" if update else "Build"
304
+ return f"{verb} {dataset.upper()} index", f"build-db:{dataset}", run
305
+
306
+
307
+ def _pgda(p: dict, ctx: JobContext) -> tuple[str, str, Callable[[Job], Any]]:
308
+ from .. import reference as ref
309
+
310
+ reg = _region(p)
311
+ if reg.pgda_site is None:
312
+ raise ValueError(f"Region {reg.id} {reg.name} has no PGDA LOLA 5 m site")
313
+ kinds = _strings(p, "kinds", len(ref.PGDA_KINDS))
314
+ bad = [k for k in kinds if k not in ref.PGDA_KINDS]
315
+ if bad:
316
+ raise ValueError(f"unknown PGDA kind(s) {bad}; choose from {', '.join(ref.PGDA_KINDS)}")
317
+ clones = p.get("clones")
318
+ if clones is not None and (not isinstance(clones, int) or isinstance(clones, bool) or clones < 1):
319
+ raise ValueError("clones must be a positive integer")
320
+ site = reg.pgda_site
321
+
322
+ def run(job: Job) -> dict[str, Any]:
323
+ from ..downloader import report_progress, url_filename
324
+
325
+ job.set_stage("Reading the PGDA file index")
326
+ files = [(k, u) for k, urls in ref.pgda_urls(site, kinds, clones).items() for u in urls]
327
+ out = ctx.reference_dir / "pgda" / site
328
+ with report_progress(job.on_bytes):
329
+ for i, (kind, url) in enumerate(files, 1):
330
+ name = url_filename(url)
331
+ job.set_file(i, len(files), name)
332
+ ref.fetch(url, out / ("Clones" if kind == "clones" else "") / name, show_progress=False)
333
+ job.info(f"{len(files)} file(s) for {site}")
334
+ return {"site": site, "files": len(files)}
335
+
336
+ return f"PGDA {site} ({', '.join(kinds)})", f"pgda:{site}", run
337
+
338
+
339
+ def _sdem(p: dict, ctx: JobContext) -> tuple[str, str, Callable[[Job], Any]]:
340
+ reg = _region(p)
341
+ members = _strings(p, "members")
342
+
343
+ def run(job: Job) -> dict[str, Any]:
344
+ from .. import reference as ref
345
+ from ..tools import reuse_existing
346
+
347
+ job.set_stage("Reading the zip directory (HTTP Range)")
348
+ out_dir = ctx.reference_dir / "sdem" / reg.zenodo_file.removesuffix(".zip")
349
+ done = []
350
+ with ref._open_zip(reg) as zf:
351
+ names = ref.select_members(zf.namelist(), members)
352
+ if not names:
353
+ raise ValueError(f"No zip member matches {members}")
354
+ for i, name in enumerate(names, 1):
355
+ info = zf.getinfo(name)
356
+ dest = out_dir / (name.split("/", 1)[1] if "/" in name else name)
357
+ job.set_file(i, len(names), dest.name)
358
+ if not reuse_existing(dest, False):
359
+ dest.parent.mkdir(parents=True, exist_ok=True)
360
+ tmp = dest.with_suffix(dest.suffix + ".part")
361
+ n = 0
362
+ with zf.open(name) as src, open(tmp, "wb") as dst:
363
+ while buf := src.read(1 << 20):
364
+ dst.write(buf)
365
+ n += len(buf)
366
+ job.on_bytes(dest.name, n, info.file_size)
367
+ tmp.replace(dest)
368
+ done.append(dest.name)
369
+ job.info(f"{len(done)} member(s) of {reg.zenodo_file}")
370
+ return {"region": reg.id, "files": len(done)}
371
+
372
+ return f"SDEM {reg.name} ({', '.join(members)})", f"sdem:{reg.id}", run
373
+
374
+
375
+ def _download(p: dict, ctx: JobContext) -> tuple[str, str, Callable[[Job], Any]]:
376
+ from ..constants import NAC_ID_RE, WAC_ID_RE
377
+
378
+ ids = [i.upper() for i in _strings(p, "ids", MAX_IDS)]
379
+ bad = [i for i in ids if not (NAC_ID_RE.match(i) or WAC_ID_RE.match(i))]
380
+ if bad:
381
+ raise ValueError(f"not LROC product IDs: {' '.join(bad[:5])}")
382
+ product = p.get("product", "edr")
383
+ if product not in ("edr", "cdr"):
384
+ raise ValueError("product must be edr or cdr")
385
+ dest_s = _str(p, "dest")
386
+ dest = Path(dest_s).expanduser() if dest_s else ctx.download_dir
387
+ if not dest.is_absolute():
388
+ raise ValueError("dest must be an absolute directory")
389
+
390
+ def run(job: Job) -> dict[str, Any]:
391
+ from ..downloader import report_progress
392
+ from ..lroc.nac import NacImageCollection
393
+
394
+ job.set_stage("Looking up the product IDs")
395
+ images = list(NacImageCollection.from_sources(ids, data_dir=ctx.data_dir))
396
+ missing = sorted(set(ids) - {img.product_id for img in images})
397
+ if missing:
398
+ job.info(f"Not in the index: {' '.join(missing[:20])}{' ...' if len(missing) > 20 else ''}")
399
+ ok, failed = [], []
400
+ with report_progress(job.on_bytes):
401
+ for i, img in enumerate(images, 1):
402
+ job.set_file(i, len(images), img.product_id)
403
+ try:
404
+ path = img.download_cdr(dest) if product == "cdr" else img.download_edr(dest)
405
+ except Cancelled:
406
+ raise
407
+ except Exception as exc:
408
+ job.info(f"{img.product_id}: {exc}")
409
+ failed.append(img.product_id)
410
+ else:
411
+ ok.append(path.name)
412
+ job.info(f"{len(ok)} downloaded, {len(failed)} failed, {len(missing)} not found")
413
+ if not ok and (failed or missing):
414
+ raise RuntimeError("nothing was downloaded")
415
+ return {"downloaded": len(ok), "failed": failed, "missing": missing}
416
+
417
+ return f"Download {len(ids)} {product.upper()} image(s)", f"download:{product}:{' '.join(ids)}", run
418
+
419
+
420
+ KINDS: dict[str, Callable[[dict, JobContext], tuple[str, str, Callable[[Job], Any]]]] = {
421
+ "build-db": _build_db,
422
+ "pgda": _pgda,
423
+ "sdem": _sdem,
424
+ "download": _download,
425
+ }
426
+
427
+
428
+ def submit(manager: JobManager, payload: Any, ctx: JobContext) -> Job:
429
+ """Validate a ``POST /api/jobs`` body and queue the job (ValueError for a bad request)."""
430
+ if not isinstance(payload, dict):
431
+ raise ValueError("expected a JSON object")
432
+ kind = payload.get("kind")
433
+ if kind not in KINDS:
434
+ raise ValueError(f"kind must be one of {', '.join(KINDS)}")
435
+ title, key, func = KINDS[kind](payload, ctx)
436
+ params = {k: v for k, v in payload.items() if k != "kind"}
437
+ return manager.submit(kind, title, key, params, func)