themisim 0.1.0__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.
themisim/__init__.py ADDED
@@ -0,0 +1,73 @@
1
+ """THEMIS all-sky imager similarity search.
2
+
3
+ A standalone, pip-installable extraction of the THEMIS similarity-search
4
+ engine: download the archive, build a FAISS index from the pretrained SimCLR
5
+ encoder, and query it by ``(site, datetime, frame)``.
6
+
7
+ Quick start (query an existing index)::
8
+
9
+ from themisim import query
10
+ df = query("fsmi", "2015-03-18T06", 412, artifacts="data/artifacts")
11
+
12
+ Build an index from scratch::
13
+
14
+ from themisim import download_archive, build_index
15
+ download_archive("data/cdf", sites=["fsmi"], start="2015-03", end="2015-03")
16
+ build_index("data/cdf", "data/artifacts", "weights/<checkpoint>.tar")
17
+ """
18
+ from __future__ import annotations
19
+
20
+ # Establish the torch-before-faiss import order before anything pulls in faiss
21
+ # (the query stack imports faiss). torch and faiss ship conflicting CUDA libs
22
+ # and break each other's loader if faiss is imported first; importing torch
23
+ # here (when present) is a no-op safety net. Absent torch (query-only installs)
24
+ # this is simply skipped and faiss loads alone, which is fine.
25
+ try: # pragma: no cover - environment dependent
26
+ import torch as _torch # noqa: F401
27
+ except Exception: # pragma: no cover
28
+ pass
29
+
30
+ from themisim.export import ( # noqa: E402
31
+ hits_to_dataframe,
32
+ results_csv_filename,
33
+ )
34
+ from themisim.query import query, resolve_global_id # noqa: E402
35
+ from themisim.search import Hit, SearchEngine # noqa: E402
36
+
37
+ __version__ = "0.1.0"
38
+
39
+ __all__ = [
40
+ "query",
41
+ "resolve_global_id",
42
+ "SearchEngine",
43
+ "Hit",
44
+ "hits_to_dataframe",
45
+ "results_csv_filename",
46
+ "download_archive",
47
+ "build_index",
48
+ "fetch_weights",
49
+ "visualize_results",
50
+ "__version__",
51
+ ]
52
+
53
+
54
+ def __getattr__(name: str):
55
+ """Lazily expose the build pipeline + weights helper.
56
+
57
+ Kept out of the eager import path so ``import themisim`` stays
58
+ cheap (and torch-free) for query-only use; ``build_index`` /
59
+ ``download_archive`` pull in the embed/index stack only when first used.
60
+ """
61
+ if name in ("build_index", "download_archive"):
62
+ from themisim import pipeline
63
+
64
+ return getattr(pipeline, name)
65
+ if name == "fetch_weights":
66
+ from themisim.weights import fetch_weights
67
+
68
+ return fetch_weights
69
+ if name == "visualize_results":
70
+ from themisim.viz import visualize_results
71
+
72
+ return visualize_results
73
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
themisim/catalog.py ADDED
@@ -0,0 +1,220 @@
1
+ """Discover CDFs on the public THEMIS archive and build a download work-list.
2
+
3
+ Distilled from the original project's ``completeness.py`` (which also did
4
+ multi-USB local diffing). The public library only needs to (1) crawl the
5
+ Berkeley Apache autoindex to find which CDFs exist for a set of sites / dates,
6
+ and (2) turn that listing into the ``(url, target_path, size_bytes)`` work-list
7
+ that :mod:`themisim.download` consumes.
8
+
9
+ Crawling is seeded per site (and per year when a date range is given) so a
10
+ filtered run does not walk the entire ~1M-file tree.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import time
16
+ import urllib.error
17
+ import urllib.parse
18
+ import urllib.request
19
+ from pathlib import Path
20
+ from typing import Callable, List, Optional, Sequence, Set
21
+
22
+ import pandas as pd
23
+
24
+ from themisim.config import ARCHIVE_BASE_URL
25
+
26
+ # Strict on `thg_l1_asf_<site>_<YYYYMMDDHH>_v01.cdf`. The tree also carries
27
+ # `thg_l1_ast_*` thumbnails; ignore those (the index is built from asf only).
28
+ #
29
+ # v01 only, deliberately: the rest of the pipeline is v01-throughout — the
30
+ # inventory walk globs `*_v01.cdf` (inventory.py), the embed filename parser
31
+ # requires `_v01` (embed.py), and the CDF download URL is reconstructed with a
32
+ # hardcoded `_v01.cdf` suffix (export.py). Matching any `_v<NN>` here would let
33
+ # the crawler queue a reprocessed `_v02.cdf` for download that every downstream
34
+ # stage then silently drops. Keeping the crawl v01-only makes the whole pipeline
35
+ # consistent; supporting reprocessed versions would require making the shard
36
+ # naming and URL reconstruction version-aware end to end.
37
+ CDF_NAME_RE = re.compile(r"^thg_l1_asf_([a-z]{4})_(\d{10})_v01\.cdf$")
38
+ HREF_RE = re.compile(rb'href="([^"?#][^"]*)"', re.I)
39
+
40
+ WORKLIST_COLUMNS = ["site", "datetime", "filename", "url", "target_path", "size_bytes"]
41
+
42
+
43
+ def _opener(user_agent: str) -> urllib.request.OpenerDirector:
44
+ opener = urllib.request.build_opener()
45
+ opener.addheaders = [("User-Agent", user_agent)]
46
+ return opener
47
+
48
+
49
+ def _list_directory(
50
+ url: str, opener: urllib.request.OpenerDirector, timeout: int = 30
51
+ ) -> List[str]:
52
+ """All href values on an Apache autoindex page (no filtering)."""
53
+ with opener.open(url, timeout=timeout) as r:
54
+ body = r.read()
55
+ return [m.decode("utf-8", "ignore") for m in HREF_RE.findall(body)]
56
+
57
+
58
+ def _seed_urls(
59
+ base_url: str,
60
+ sites: Optional[Sequence[str]],
61
+ start: Optional[str],
62
+ end: Optional[str],
63
+ ) -> List[str]:
64
+ """Starting directories for the crawl.
65
+
66
+ With no site filter we crawl from ``base_url`` (the whole archive). With
67
+ sites we crawl each ``<base>/<site>/``; if a year range is derivable from
68
+ ``start``/``end`` we descend to ``<base>/<site>/<YYYY>/`` so a narrow date
69
+ request touches only the relevant years.
70
+ """
71
+ if not base_url.endswith("/"):
72
+ base_url += "/"
73
+ if not sites:
74
+ return [base_url]
75
+
76
+ years: Optional[List[int]] = None
77
+ if start or end:
78
+ y0 = int(start[:4]) if start else 1990
79
+ y1 = int(end[:4]) if end else 2100
80
+ years = list(range(y0, y1 + 1))
81
+
82
+ seeds: List[str] = []
83
+ for site in sites:
84
+ site_base = urllib.parse.urljoin(base_url, f"{site}/")
85
+ if years is None:
86
+ seeds.append(site_base)
87
+ else:
88
+ for y in years:
89
+ seeds.append(urllib.parse.urljoin(site_base, f"{y}/"))
90
+ return seeds
91
+
92
+
93
+ def _in_range(dtstr: str, start: Optional[str], end: Optional[str]) -> bool:
94
+ """Is the ``YYYYMMDDHH`` stamp within ``[start, end]``?
95
+
96
+ ``start``/``end`` are compared as left-anchored prefixes of ``dtstr``
97
+ (``YYYY``, ``YYYY-MM`` or ``YYYYMM`` etc.), so ``--start 2015-03`` keeps
98
+ everything from March 2015 onward.
99
+ """
100
+ def norm(s: str) -> str:
101
+ return s.replace("-", "")
102
+
103
+ if start and dtstr < norm(start).ljust(len(dtstr), "0"):
104
+ return False
105
+ if end:
106
+ e = norm(end)
107
+ # Inclusive upper bound: pad with '9' so a YYYYMM end keeps that month.
108
+ if dtstr > e.ljust(len(dtstr), "9"):
109
+ return False
110
+ return True
111
+
112
+
113
+ def crawl(
114
+ sites: Optional[Sequence[str]] = None,
115
+ *,
116
+ start: Optional[str] = None,
117
+ end: Optional[str] = None,
118
+ base_url: str = ARCHIVE_BASE_URL,
119
+ rate_limit_s: float = 0.3,
120
+ user_agent: str = "themis-asi-search/0.1",
121
+ cache_path: Optional[Path] = None,
122
+ on_dir: Optional[Callable[[int, int, int, str], None]] = None,
123
+ ) -> pd.DataFrame:
124
+ """BFS the archive autoindex and return discovered CDFs.
125
+
126
+ Returns a DataFrame with columns ``site, datetime, filename, remote_url``.
127
+ If ``cache_path`` is given the listing is checkpointed there and resumed on
128
+ a re-run (an interrupted crawl loses at most a few directory listings).
129
+ """
130
+ opener = _opener(user_agent)
131
+ rows: List[dict] = []
132
+ seen: Set[str] = set()
133
+
134
+ if cache_path is not None and Path(cache_path).exists():
135
+ prev = pd.read_parquet(cache_path)
136
+ rows.extend(prev.to_dict("records"))
137
+ seen.update(prev["remote_url"].tolist())
138
+
139
+ queue: List[str] = _seed_urls(base_url, sites, start, end)
140
+ dirs_done = 0
141
+ last_ckpt = 0
142
+
143
+ while queue:
144
+ url = queue.pop(0)
145
+ time.sleep(rate_limit_s)
146
+ try:
147
+ hrefs = _list_directory(url, opener)
148
+ except (urllib.error.URLError, TimeoutError) as exc:
149
+ if on_dir is not None:
150
+ on_dir(dirs_done, len(queue), len(rows), f"WARN {url}: {exc}")
151
+ continue
152
+
153
+ for h in hrefs:
154
+ if h in ("../", "..") or h.startswith("?") or h.startswith("/"):
155
+ continue
156
+ child = urllib.parse.urljoin(url, h)
157
+ if h.endswith("/"):
158
+ queue.append(child)
159
+ continue
160
+ m = CDF_NAME_RE.match(h)
161
+ if not m:
162
+ continue
163
+ if not _in_range(m.group(2), start, end):
164
+ continue
165
+ if child in seen:
166
+ continue
167
+ seen.add(child)
168
+ rows.append(
169
+ {
170
+ "site": m.group(1),
171
+ "datetime": m.group(2),
172
+ "filename": h,
173
+ "remote_url": child,
174
+ }
175
+ )
176
+
177
+ dirs_done += 1
178
+ if on_dir is not None:
179
+ on_dir(dirs_done, len(queue), len(rows), url)
180
+ if cache_path is not None and dirs_done - last_ckpt >= 50:
181
+ _write(rows, Path(cache_path))
182
+ last_ckpt = dirs_done
183
+
184
+ if cache_path is not None:
185
+ _write(rows, Path(cache_path))
186
+ return pd.DataFrame(rows, columns=["site", "datetime", "filename", "remote_url"])
187
+
188
+
189
+ def _write(rows: List[dict], out_path: Path) -> None:
190
+ if not rows:
191
+ return
192
+ out_path.parent.mkdir(parents=True, exist_ok=True)
193
+ pd.DataFrame(rows).drop_duplicates("remote_url").reset_index(drop=True).to_parquet(
194
+ out_path, engine="pyarrow", index=False
195
+ )
196
+
197
+
198
+ def build_worklist(crawl_df: pd.DataFrame, data_root: Path) -> pd.DataFrame:
199
+ """Turn a crawl listing into a download work-list.
200
+
201
+ Each CDF's local target mirrors the archive layout
202
+ ``<data_root>/<site>/<YYYY>/<MM>/<filename>``. ``size_bytes`` is left at 0;
203
+ the downloader HEADs each URL for the authoritative size.
204
+ """
205
+ data_root = Path(data_root)
206
+ rows = []
207
+ for r in crawl_df.itertuples(index=False):
208
+ yyyy, mm = r.datetime[:4], r.datetime[4:6]
209
+ target = data_root / r.site / yyyy / mm / r.filename
210
+ rows.append(
211
+ {
212
+ "site": r.site,
213
+ "datetime": r.datetime,
214
+ "filename": r.filename,
215
+ "url": r.remote_url,
216
+ "target_path": str(target),
217
+ "size_bytes": 0,
218
+ }
219
+ )
220
+ return pd.DataFrame(rows, columns=WORKLIST_COLUMNS)
@@ -0,0 +1 @@
1
+ """Command-line entry points for themis-asi-search."""
@@ -0,0 +1,96 @@
1
+ """``themis-build-index`` — embed downloaded CDFs and build the FAISS index.
2
+
3
+ Runs the full build: inventory -> embed (SimCLR encoder) -> concatenate ->
4
+ train + add to an OPQ-IVF-PQ index. Uses the GPU when available and falls back
5
+ to CPU otherwise. Resumable — already-embedded hours are skipped.
6
+
7
+ Example::
8
+
9
+ themis-build-index --data-root ./data/cdf --artifacts ./data/artifacts \\
10
+ --checkpoint ./weights/aurora-fm-no-finetune.tar
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import time
16
+ from pathlib import Path
17
+
18
+ from themisim.config import default_artifacts_root, default_data_root
19
+
20
+
21
+ def main(argv: list[str] | None = None) -> int:
22
+ p = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
23
+ p.add_argument("--data-root", type=Path, default=default_data_root())
24
+ p.add_argument("--artifacts", type=Path, default=default_artifacts_root())
25
+ p.add_argument(
26
+ "--checkpoint",
27
+ type=Path,
28
+ default=None,
29
+ help="SimCLR checkpoint .tar (default: fetch/verify into the weights dir)",
30
+ )
31
+ p.add_argument(
32
+ "--nlist",
33
+ default="auto",
34
+ help="IVF cells; 'auto' scales with the vector count (default: auto)",
35
+ )
36
+ p.add_argument(
37
+ "--device",
38
+ default="auto",
39
+ help="'auto' (GPU if present, else CPU), 'cuda', or 'cpu'",
40
+ )
41
+ p.add_argument("--batch-size", type=int, default=128)
42
+ p.add_argument("--num-workers", type=int, default=4, help="DataLoader workers")
43
+ p.add_argument(
44
+ "--no-gpu-quantizer",
45
+ action="store_true",
46
+ help="train the IVF k-means on CPU even if GPUs are present",
47
+ )
48
+ p.add_argument(
49
+ "--no-streaming",
50
+ action="store_true",
51
+ help="use the eager per-CDF embed loop instead of the faster "
52
+ "persistent-worker streaming path",
53
+ )
54
+ p.add_argument(
55
+ "--no-compile",
56
+ action="store_true",
57
+ help="skip torch.compile on the encoder during streaming embed "
58
+ "(avoids its warmup; only affects GPU runs)",
59
+ )
60
+ args = p.parse_args(argv)
61
+
62
+ from themisim.pipeline import build_index
63
+
64
+ checkpoint = args.checkpoint
65
+ if checkpoint is None:
66
+ from themisim.weights import fetch_weights
67
+
68
+ print("No --checkpoint given; fetching/verifying weights ...", flush=True)
69
+ checkpoint = fetch_weights()
70
+
71
+ t0 = time.time()
72
+
73
+ def progress(stage: str, info: object) -> None:
74
+ print(f" [{stage}] {info} (+{time.time() - t0:.0f}s)", flush=True)
75
+
76
+ nlist = args.nlist if args.nlist == "auto" else int(args.nlist)
77
+ out = build_index(
78
+ args.data_root,
79
+ args.artifacts,
80
+ checkpoint,
81
+ nlist=nlist,
82
+ device=args.device,
83
+ batch_size=args.batch_size,
84
+ num_workers=args.num_workers,
85
+ use_gpu_quantizer=not args.no_gpu_quantizer,
86
+ streaming=not args.no_streaming,
87
+ use_compile=not args.no_compile,
88
+ on_progress=progress,
89
+ )
90
+ size = out.stat().st_size
91
+ print(f"\nWrote {out} ({size / 1e6:.1f} MB)")
92
+ return 0
93
+
94
+
95
+ if __name__ == "__main__":
96
+ raise SystemExit(main())
@@ -0,0 +1,86 @@
1
+ """``themis-download`` — fetch THEMIS ASI CDFs from the Berkeley archive.
2
+
3
+ Examples
4
+ --------
5
+ Full archive (every site, every date)::
6
+
7
+ themis-download --data-root ./data/cdf
8
+
9
+ A single site and month::
10
+
11
+ themis-download --data-root ./data/cdf --sites fsmi --start 2015-03 --end 2015-03
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from themisim.config import default_data_root
20
+
21
+
22
+ def _split_sites(value: str | None) -> list[str] | None:
23
+ if not value:
24
+ return None
25
+ return [s.strip().lower() for s in value.split(",") if s.strip()]
26
+
27
+
28
+ def main(argv: list[str] | None = None) -> int:
29
+ p = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
30
+ p.add_argument(
31
+ "--data-root",
32
+ type=Path,
33
+ default=default_data_root(),
34
+ help="where to store CDFs (default: $THEMIS_ASI_DATA_ROOT or ./data/cdf)",
35
+ )
36
+ p.add_argument(
37
+ "--sites",
38
+ default=None,
39
+ help="comma-separated 4-letter site codes (default: all sites)",
40
+ )
41
+ p.add_argument("--start", default=None, help="earliest date, e.g. 2015 or 2015-03")
42
+ p.add_argument("--end", default=None, help="latest date, e.g. 2016 or 2016-12")
43
+ p.add_argument("--workers", type=int, default=6, help="parallel downloads")
44
+ p.add_argument("--limit", type=int, default=None, help="cap files (smoke tests)")
45
+ args = p.parse_args(argv)
46
+
47
+ # Imported here so `themis-download --help` doesn't pay the import cost.
48
+ from themisim.pipeline import download_archive
49
+
50
+ state = {"t0": time.time()}
51
+
52
+ def on_dir(done: int, queued: int, found: int, msg: str) -> None:
53
+ if done % 25 == 0 or msg.startswith("WARN"):
54
+ print(f"[crawl] dirs={done} queued={queued} found={found:,} {msg}", flush=True)
55
+
56
+ def on_event(res, done: int, total: int) -> None:
57
+ if res.status in ("failed", "stopped"):
58
+ print(f" [{res.status}] {res.url} {res.error}", flush=True)
59
+ if done % 50 == 0 or done == total:
60
+ dt = max(time.time() - state["t0"], 1e-6)
61
+ print(f" {done:,}/{total:,} files ({done / dt:.1f}/s)", flush=True)
62
+
63
+ print(f"Crawling archive (sites={args.sites or 'ALL'}, "
64
+ f"start={args.start}, end={args.end}) ...", flush=True)
65
+ res = download_archive(
66
+ args.data_root,
67
+ sites=_split_sites(args.sites),
68
+ start=args.start,
69
+ end=args.end,
70
+ workers=args.workers,
71
+ limit=args.limit,
72
+ on_dir=on_dir,
73
+ on_event=on_event,
74
+ )
75
+
76
+ print("\n=== summary ===")
77
+ if len(res):
78
+ print(res.groupby("status").size().to_string())
79
+ print(f"bytes downloaded: {res['bytes_written'].sum() / 1e9:.2f} GB")
80
+ else:
81
+ print("(no files matched)")
82
+ return 0
83
+
84
+
85
+ if __name__ == "__main__":
86
+ raise SystemExit(main())
themisim/cli/query.py ADDED
@@ -0,0 +1,93 @@
1
+ """``themis-query`` — similarity search by ``(site, datetime, frame)``.
2
+
3
+ Writes (or prints) a CSV identical to the dashboard's export:
4
+ ``site, datetime, score, source_cdf``.
5
+
6
+ Example::
7
+
8
+ themis-query --site fsmi --datetime 2015-03-18T06 --frame 412 \\
9
+ --artifacts ./data/artifacts --output results.csv
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ from pathlib import Path
15
+
16
+ from themisim.config import default_artifacts_root
17
+ from themisim.search import (
18
+ DEFAULT_DIVERSIFY_SECONDS,
19
+ DEFAULT_K,
20
+ DEFAULT_NPROBE,
21
+ DEFAULT_PREFILTER,
22
+ )
23
+
24
+
25
+ def main(argv: list[str] | None = None) -> int:
26
+ p = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
27
+ p.add_argument("--site", required=True, help="4-letter site code, e.g. fsmi")
28
+ p.add_argument(
29
+ "--datetime",
30
+ required=True,
31
+ help="hourly CDF, e.g. 2015-03-18T06 (sub-hour fields ignored)",
32
+ )
33
+ p.add_argument("--frame", type=int, required=True, help="0-based frame in the hour")
34
+ p.add_argument("--artifacts", type=Path, default=default_artifacts_root())
35
+ p.add_argument(
36
+ "--results",
37
+ type=int,
38
+ default=DEFAULT_K,
39
+ help="count mode: number of top matches to return (ignored with --min-score)",
40
+ )
41
+ p.add_argument(
42
+ "--min-score",
43
+ type=float,
44
+ default=None,
45
+ help="threshold mode: return every match with cosine similarity >= this "
46
+ "cutoff (0..1), best first, instead of a fixed --results count",
47
+ )
48
+ p.add_argument("--prefilter", type=int, default=DEFAULT_PREFILTER)
49
+ p.add_argument("--nprobe", type=int, default=DEFAULT_NPROBE)
50
+ p.add_argument("--diversify-seconds", type=int, default=DEFAULT_DIVERSIFY_SECONDS)
51
+ p.add_argument(
52
+ "--output",
53
+ type=Path,
54
+ default=None,
55
+ help="CSV path. Default: auto-named in the cwd; '-' prints to stdout",
56
+ )
57
+ args = p.parse_args(argv)
58
+
59
+ from themisim.export import results_csv_filename
60
+ from themisim.query import get_engine, query, resolve_global_id
61
+
62
+ engine = get_engine(args.artifacts)
63
+ # Resolve once so we can name the output file the way the dashboard does.
64
+ gid = resolve_global_id(engine, args.site, args.datetime, args.frame)
65
+ df = query(
66
+ args.site,
67
+ args.datetime,
68
+ args.frame,
69
+ results=args.results,
70
+ min_score=args.min_score,
71
+ prefilter=args.prefilter,
72
+ nprobe=args.nprobe,
73
+ diversify_seconds=args.diversify_seconds,
74
+ engine=engine,
75
+ )
76
+
77
+ if str(args.output) == "-":
78
+ print(df.to_csv(index=False))
79
+ return 0
80
+
81
+ if args.output is None:
82
+ out = Path.cwd() / results_csv_filename(
83
+ args.site, int(engine.time_ns_of(gid))
84
+ )
85
+ else:
86
+ out = args.output
87
+ df.to_csv(out, index=False)
88
+ print(f"Wrote {len(df)} rows -> {out}")
89
+ return 0
90
+
91
+
92
+ if __name__ == "__main__":
93
+ raise SystemExit(main())