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 +73 -0
- themisim/catalog.py +220 -0
- themisim/cli/__init__.py +1 -0
- themisim/cli/build_index.py +96 -0
- themisim/cli/download.py +86 -0
- themisim/cli/query.py +93 -0
- themisim/concat.py +374 -0
- themisim/config.py +62 -0
- themisim/download.py +289 -0
- themisim/embed.py +1346 -0
- themisim/export.py +79 -0
- themisim/index.py +429 -0
- themisim/inventory.py +167 -0
- themisim/model.py +66 -0
- themisim/pipeline.py +206 -0
- themisim/preprocess.py +80 -0
- themisim/query.py +245 -0
- themisim/search.py +713 -0
- themisim/viz.py +173 -0
- themisim/weights.py +114 -0
- themisim-0.1.0.dist-info/METADATA +196 -0
- themisim-0.1.0.dist-info/RECORD +26 -0
- themisim-0.1.0.dist-info/WHEEL +5 -0
- themisim-0.1.0.dist-info/entry_points.txt +4 -0
- themisim-0.1.0.dist-info/licenses/LICENSE +21 -0
- themisim-0.1.0.dist-info/top_level.txt +1 -0
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)
|
themisim/cli/__init__.py
ADDED
|
@@ -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())
|
themisim/cli/download.py
ADDED
|
@@ -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())
|