astrooracle 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AstroOracle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: astrooracle
3
+ Version: 0.1.0
4
+ Summary: AstroOracle: active learning oracle for astronomical anomaly triage (SkyView cutouts + annotations + retrain hooks).
5
+ Author: AstroOracle Contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/<OWNER>/AstroOracle
8
+ Project-URL: Issues, https://github.com/<OWNER>/AstroOracle/issues
9
+ Keywords: astronomy,anomaly-detection,active-learning,skyview,astropy
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: numpy>=1.23
23
+ Requires-Dist: pandas>=2.0
24
+ Requires-Dist: matplotlib>=3.7
25
+ Requires-Dist: astropy>=5.3
26
+ Requires-Dist: astroquery>=0.4.6
27
+ Requires-Dist: scikit-learn>=1.3
28
+ Requires-Dist: pyarrow>=12.0
29
+ Requires-Dist: pillow>=10.0
30
+ Provides-Extra: notebook
31
+ Requires-Dist: ipywidgets>=8.1; extra == "notebook"
32
+ Requires-Dist: jupyterlab>=4.0; extra == "notebook"
33
+ Requires-Dist: plotly>=5.18; extra == "notebook"
34
+ Provides-Extra: plotly
35
+ Requires-Dist: plotly>=5.18; extra == "plotly"
36
+ Provides-Extra: watch
37
+ Requires-Dist: watchdog>=3.0; extra == "watch"
38
+ Provides-Extra: dev
39
+ Requires-Dist: pytest<10,>=8; extra == "dev"
40
+ Requires-Dist: pytest-cov<8,>=5; extra == "dev"
41
+ Requires-Dist: coverage[toml]<8,>=7; extra == "dev"
42
+ Requires-Dist: ruff<1.0,>=0.6; extra == "dev"
43
+ Requires-Dist: black<27,>=24; extra == "dev"
44
+ Requires-Dist: pre-commit<5,>=3.7; extra == "dev"
45
+ Requires-Dist: mypy<2,>=1.10; extra == "dev"
46
+ Requires-Dist: types-requests>=2.32.0.20240914; extra == "dev"
47
+ Requires-Dist: build<2,>=1.2; extra == "dev"
48
+ Requires-Dist: twine<7,>=5; extra == "dev"
49
+ Requires-Dist: nox>=2024.4.15; extra == "dev"
50
+ Dynamic: license-file
51
+
52
+ # AstroOracle
53
+
54
+ AstroOracle is an active learning “oracle” for astronomical anomaly triage.
55
+
56
+ - Reads candidate detections from a Parquet file.
57
+ - Ranks them by uncertainty + optional embedding diversity.
58
+ - Pulls multi-survey cutouts from SkyView.
59
+ - Annotates via CLI (matplotlib) or Jupyter (ipywidgets + Plotly).
60
+ - Logs events in append-only JSONL.
61
+ - Optionally triggers retraining.
62
+
63
+ ## Install (editable)
64
+
65
+ ```bash
66
+ python -m venv .venv
67
+ source .venv/bin/activate
68
+ pip install -U pip
69
+ pip install -e ".[plotly,watch,notebook]"
70
+ ```
71
+
72
+ ## Quickstart (CLI)
73
+
74
+ Generate sample candidates:
75
+
76
+ ```bash
77
+ python examples/sample_candidates.py
78
+ ```
79
+
80
+ Run oracle:
81
+
82
+ ```bash
83
+ astrooracle run --candidates candidates.parquet --interval 300 --n-query 6
84
+ ```
85
+
86
+ Headless + PNG export:
87
+
88
+ ```bash
89
+ astrooracle run --no-gui --save-cutouts cutouts/ --candidates candidates.parquet
90
+ ```
91
+
92
+ ## Jupyter UI
93
+
94
+ ```bash
95
+ jupyter lab
96
+ ```
97
+
98
+ Open `examples/oracle_notebook.ipynb`.
99
+
100
+ ## Watch mode (watchdog)
101
+
102
+ ```bash
103
+ astrooracle watch --candidates candidates.parquet
104
+ ```
105
+
106
+ ## Batch HTML export
107
+
108
+ ```bash
109
+ astrooracle batch-html --candidates candidates.parquet --out-dir batch_out/ --n-query 60
110
+ ```
111
+
112
+ Open `batch_out/index.html` and export annotations from the page.
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,65 @@
1
+ # AstroOracle
2
+
3
+ AstroOracle is an active learning “oracle” for astronomical anomaly triage.
4
+
5
+ - Reads candidate detections from a Parquet file.
6
+ - Ranks them by uncertainty + optional embedding diversity.
7
+ - Pulls multi-survey cutouts from SkyView.
8
+ - Annotates via CLI (matplotlib) or Jupyter (ipywidgets + Plotly).
9
+ - Logs events in append-only JSONL.
10
+ - Optionally triggers retraining.
11
+
12
+ ## Install (editable)
13
+
14
+ ```bash
15
+ python -m venv .venv
16
+ source .venv/bin/activate
17
+ pip install -U pip
18
+ pip install -e ".[plotly,watch,notebook]"
19
+ ```
20
+
21
+ ## Quickstart (CLI)
22
+
23
+ Generate sample candidates:
24
+
25
+ ```bash
26
+ python examples/sample_candidates.py
27
+ ```
28
+
29
+ Run oracle:
30
+
31
+ ```bash
32
+ astrooracle run --candidates candidates.parquet --interval 300 --n-query 6
33
+ ```
34
+
35
+ Headless + PNG export:
36
+
37
+ ```bash
38
+ astrooracle run --no-gui --save-cutouts cutouts/ --candidates candidates.parquet
39
+ ```
40
+
41
+ ## Jupyter UI
42
+
43
+ ```bash
44
+ jupyter lab
45
+ ```
46
+
47
+ Open `examples/oracle_notebook.ipynb`.
48
+
49
+ ## Watch mode (watchdog)
50
+
51
+ ```bash
52
+ astrooracle watch --candidates candidates.parquet
53
+ ```
54
+
55
+ ## Batch HTML export
56
+
57
+ ```bash
58
+ astrooracle batch-html --candidates candidates.parquet --out-dir batch_out/ --n-query 60
59
+ ```
60
+
61
+ Open `batch_out/index.html` and export annotations from the page.
62
+
63
+ ## License
64
+
65
+ MIT
@@ -0,0 +1,114 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "astrooracle"
7
+ version = "0.1.0"
8
+ description = "AstroOracle: active learning oracle for astronomical anomaly triage (SkyView cutouts + annotations + retrain hooks)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "AstroOracle Contributors" }]
13
+ keywords = ["astronomy", "anomaly-detection", "active-learning", "skyview", "astropy"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Scientific/Engineering :: Astronomy",
24
+ ]
25
+ dependencies = [
26
+ "numpy>=1.23",
27
+ "pandas>=2.0",
28
+ "matplotlib>=3.7",
29
+ "astropy>=5.3",
30
+ "astroquery>=0.4.6",
31
+ "scikit-learn>=1.3",
32
+ "pyarrow>=12.0",
33
+ "pillow>=10.0",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/<OWNER>/AstroOracle"
38
+ Issues = "https://github.com/<OWNER>/AstroOracle/issues"
39
+
40
+ [project.optional-dependencies]
41
+ notebook = [
42
+ "ipywidgets>=8.1",
43
+ "jupyterlab>=4.0",
44
+ "plotly>=5.18",
45
+ ]
46
+ plotly = ["plotly>=5.18"]
47
+ watch = ["watchdog>=3.0"]
48
+
49
+ # A single extra that makes CI/dev reproducible.
50
+ # Pins are intentionally bounded to avoid surprise breakage in CI.
51
+ dev = [
52
+ "pytest>=8,<10",
53
+ "pytest-cov>=5,<8",
54
+ "coverage[toml]>=7,<8",
55
+ "ruff>=0.6,<1.0",
56
+ "black>=24,<27",
57
+ "pre-commit>=3.7,<5",
58
+ "mypy>=1.10,<2",
59
+ "types-requests>=2.32.0.20240914",
60
+ "build>=1.2,<2",
61
+ "twine>=5,<7",
62
+ "nox>=2024.4.15",
63
+ ]
64
+
65
+
66
+ [project.scripts]
67
+ astrooracle = "astrooracle.cli:main"
68
+
69
+ [tool.setuptools]
70
+ package-dir = { "" = "src" }
71
+
72
+ [tool.setuptools.packages.find]
73
+ where = ["src"]
74
+
75
+ [tool.setuptools.package-data]
76
+ astrooracle = ["py.typed"]
77
+
78
+ [tool.pytest.ini_options]
79
+ minversion = "8.0"
80
+ addopts = "-q --disable-warnings --maxfail=1 --cov=astrooracle --cov-report=term-missing"
81
+ testpaths = ["tests"]
82
+
83
+ [tool.coverage.run]
84
+ branch = true
85
+ source = ["astrooracle"]
86
+
87
+ [tool.coverage.report]
88
+ fail_under = 0
89
+ show_missing = true
90
+ skip_covered = false
91
+
92
+ [tool.ruff]
93
+ line-length = 100
94
+ target-version = "py310"
95
+ src = ["src"]
96
+
97
+ [tool.ruff.lint]
98
+ select = ["E", "F", "I", "B", "UP", "SIM", "RUF"]
99
+ ignore = ["E501"] # black handles line-length
100
+
101
+ [tool.black]
102
+ line-length = 100
103
+ target-version = ["py310"]
104
+
105
+ [tool.mypy]
106
+ python_version = "3.10"
107
+ warn_return_any = true
108
+ warn_unused_configs = true
109
+ disallow_untyped_defs = false
110
+ no_implicit_optional = true
111
+ check_untyped_defs = true
112
+ strict_equality = true
113
+ ignore_missing_imports = true
114
+ mypy_path = "src"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from .config import OracleConfig
10
+
11
+
12
+ def append_annotations(cfg: OracleConfig, rows: list[dict[str, Any]]) -> None:
13
+ if not rows:
14
+ return
15
+ df_new = pd.DataFrame(rows)
16
+ cfg.annot_path.parent.mkdir(parents=True, exist_ok=True)
17
+ write_header = not cfg.annot_path.exists()
18
+ df_new.to_csv(cfg.annot_path, mode="a", header=write_header, index=False)
19
+
20
+
21
+ def read_annotations(cfg: OracleConfig) -> pd.DataFrame:
22
+ if not cfg.annot_path.exists():
23
+ return pd.DataFrame()
24
+ df = pd.read_csv(cfg.annot_path)
25
+ if "embedding" in df.columns and not df.empty:
26
+
27
+ def _loads(x):
28
+ if pd.isna(x):
29
+ return None
30
+ if isinstance(x, str) and x.strip():
31
+ try:
32
+ return np.array(json.loads(x), dtype=float)
33
+ except Exception:
34
+ return None
35
+ return None
36
+
37
+ df["embedding_vec"] = df["embedding"].map(_loads)
38
+ return df
39
+
40
+
41
+ def count_labels(cfg: OracleConfig) -> int:
42
+ if not cfg.annot_path.exists():
43
+ return 0
44
+ with cfg.annot_path.open("r", encoding="utf-8") as f:
45
+ n = sum(1 for _ in f)
46
+ return max(n - 1, 0)
47
+
48
+
49
+ def get_retrain_cursor(cfg: OracleConfig) -> int:
50
+ cursor_path = cfg.annot_path.with_suffix(".cursor.json")
51
+ if not cursor_path.exists():
52
+ return 0
53
+ try:
54
+ return int(json.loads(cursor_path.read_text(encoding="utf-8")).get("rows", 0))
55
+ except Exception:
56
+ return 0
57
+
58
+
59
+ def set_retrain_cursor(cfg: OracleConfig, rows: int) -> None:
60
+ cursor_path = cfg.annot_path.with_suffix(".cursor.json")
61
+ cursor_path.write_text(json.dumps({"rows": rows}), encoding="utf-8")
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from PIL import Image
11
+
12
+ from .config import OracleConfig
13
+ from .core import fetch_cutouts
14
+
15
+
16
+ def _slug(s: Any) -> str:
17
+ return re.sub(r"[^a-zA-Z0-9_-]+", "_", str(s))
18
+
19
+
20
+ def _save_png(array2d: np.ndarray, path: Path) -> None:
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+ arr = np.asarray(array2d, dtype=float)
23
+ finite = arr[np.isfinite(arr)]
24
+ if finite.size == 0:
25
+ norm = np.zeros_like(arr, dtype=np.uint8)
26
+ else:
27
+ lo = np.percentile(finite, 1)
28
+ hi = np.percentile(finite, 99)
29
+ if hi <= lo:
30
+ hi = lo + 1.0
31
+ norm = np.clip((arr - lo) / (hi - lo), 0, 1)
32
+ norm = (norm * 255).astype(np.uint8)
33
+ Image.fromarray(norm).save(path)
34
+
35
+
36
+ def generate_batch_html(cfg: OracleConfig, candidates_df: pd.DataFrame, out_dir: Path) -> None:
37
+ out_dir.mkdir(parents=True, exist_ok=True)
38
+ cut_dir = out_dir / "cutouts"
39
+ cut_dir.mkdir(exist_ok=True)
40
+
41
+ recs: list[dict[str, Any]] = []
42
+ for _, r in candidates_df.iterrows():
43
+ ra = float(r["ra"])
44
+ dec = float(r["dec"])
45
+ cid = r["id"]
46
+ cutouts = fetch_cutouts(ra, dec, cfg)
47
+
48
+ surveys = []
49
+ for survey, data in cutouts:
50
+ surveys.append(survey)
51
+ png = cut_dir / f"{_slug(cid)}_{_slug(survey)}.png"
52
+ _save_png(data, png)
53
+
54
+ recs.append(
55
+ {
56
+ "id": str(cid),
57
+ "ra": ra,
58
+ "dec": dec,
59
+ "score": float(r["anomaly_score"]),
60
+ "surveys": surveys,
61
+ }
62
+ )
63
+
64
+ (out_dir / "candidates.json").write_text(json.dumps(recs, indent=2), encoding="utf-8")
65
+
66
+ template = Path(__file__).resolve().parents[2] / "templates" / "batch_index.html"
67
+ (out_dir / "index.html").write_text(template.read_text(encoding="utf-8"), encoding="utf-8")
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import subprocess
6
+ import time
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from .annotations import append_annotations, count_labels, get_retrain_cursor, set_retrain_cursor
13
+ from .batch_html import generate_batch_html
14
+ from .config import OracleConfig
15
+ from .core import fetch_cutouts, load_candidates, select_candidates
16
+ from .logging_utils import log_event
17
+ from .viz_matplotlib import render_cutouts_matplotlib
18
+ from .watch import watch_candidates
19
+
20
+
21
+ def _make_cfg(args: argparse.Namespace) -> OracleConfig:
22
+ cfg0 = OracleConfig.default()
23
+ save_dir = Path(args.save_cutouts) if args.save_cutouts else None
24
+ cutout_radius = (
25
+ cfg0.cutout_radius
26
+ if args.cutout_radius_arcmin is None
27
+ else (float(args.cutout_radius_arcmin) * cfg0.cutout_radius.unit)
28
+ )
29
+ return OracleConfig(
30
+ candidates_path=Path(args.candidates),
31
+ annot_path=Path(args.annotations),
32
+ log_path=Path(args.log),
33
+ retrain_script=Path(args.retrain_script),
34
+ model_path=Path(args.model_path),
35
+ min_new_labels_for_retrain=int(args.min_new_labels),
36
+ check_interval_s=int(args.interval),
37
+ surveys=list(args.survey),
38
+ cutout_radius=cutout_radius,
39
+ pixels=int(args.pixels),
40
+ n_query=int(args.n_query),
41
+ no_gui=bool(args.no_gui),
42
+ save_cutouts_dir=save_dir,
43
+ )
44
+
45
+
46
+ def maybe_retrain(cfg: OracleConfig) -> None:
47
+ total = count_labels(cfg)
48
+ cursor = get_retrain_cursor(cfg)
49
+ new_since = total - cursor
50
+ if new_since >= cfg.min_new_labels_for_retrain:
51
+ print(f"Retrain triggered: {new_since} new labels.")
52
+ log_event(
53
+ cfg, {"event": "retrain_triggered", "new_labels": new_since, "total_labels": total}
54
+ )
55
+ try:
56
+ subprocess.run(["python", str(cfg.retrain_script)], check=True)
57
+ set_retrain_cursor(cfg, total)
58
+ log_event(cfg, {"event": "retrain_success"})
59
+ except Exception as e:
60
+ log_event(cfg, {"event": "retrain_failed", "error": str(e)})
61
+ print(f"Retrain failed: {e}")
62
+
63
+
64
+ def annotate_batch(cfg: OracleConfig, top: pd.DataFrame) -> None:
65
+ label_map = {"r": "real_anomaly", "a": "artefact", "c": "known", "j": "new_type"}
66
+
67
+ new_rows = []
68
+ has_emb = "embedding" in top.columns
69
+
70
+ for _, row in top.iterrows():
71
+ title = (
72
+ f"ID: {row['id']} | Score: {float(row['anomaly_score']):.4f} | "
73
+ f"RA: {float(row['ra']):.5f} Dec: {float(row['dec']):.5f}"
74
+ )
75
+ print("\n" + "=" * 60)
76
+ print(title)
77
+
78
+ cutouts = fetch_cutouts(float(row["ra"]), float(row["dec"]), cfg)
79
+
80
+ save_path = None
81
+ if cfg.save_cutouts_dir is not None:
82
+ safe_id = str(row["id"]).replace("/", "_")
83
+ save_path = cfg.save_cutouts_dir / f"{safe_id}.png"
84
+
85
+ render_cutouts_matplotlib(cutouts, title, cfg, save_path=save_path)
86
+
87
+ while True:
88
+ choice = (
89
+ input("Label ? [r]éel [a]rtefact [c]onnu [j]junk/nouveau [s]kip -> ")
90
+ .strip()
91
+ .lower()
92
+ )
93
+ if choice in {"r", "a", "c", "j", "s"}:
94
+ break
95
+
96
+ if choice == "s":
97
+ continue
98
+
99
+ comment = input("Commentaire (Enter pour skip): ").strip()
100
+ entry = {
101
+ "id": row["id"],
102
+ "ra": float(row["ra"]),
103
+ "dec": float(row["dec"]),
104
+ "anomaly_score": float(row["anomaly_score"]),
105
+ "label": label_map[choice],
106
+ "comment": comment,
107
+ "annotated_at": pd.Timestamp.utcnow().isoformat(),
108
+ }
109
+
110
+ if has_emb and row.get("embedding", None) is not None:
111
+ entry["embedding"] = json.dumps(np.asarray(row["embedding"], dtype=float).tolist())
112
+
113
+ new_rows.append(entry)
114
+
115
+ if new_rows:
116
+ append_annotations(cfg, new_rows)
117
+ log_event(cfg, {"event": "new_annotations", "count": len(new_rows)})
118
+ maybe_retrain(cfg)
119
+
120
+
121
+ def cmd_run(args: argparse.Namespace) -> None:
122
+ cfg = _make_cfg(args)
123
+ log_event(cfg, {"event": "oracle_started", "mode": "poll"})
124
+ print(f"AstroOracle running. candidates={cfg.candidates_path} interval={cfg.check_interval_s}s")
125
+
126
+ while True:
127
+ try:
128
+ df = load_candidates(cfg)
129
+ if not df.empty:
130
+ top = select_candidates(df, cfg)
131
+ if not top.empty:
132
+ annotate_batch(cfg, top)
133
+ except KeyboardInterrupt:
134
+ log_event(cfg, {"event": "oracle_stopped"})
135
+ print("Stopped.")
136
+ break
137
+ except Exception as e:
138
+ log_event(cfg, {"event": "error", "msg": str(e)})
139
+ print(f"Error: {e}")
140
+
141
+ time.sleep(cfg.check_interval_s)
142
+
143
+
144
+ def cmd_watch(args: argparse.Namespace) -> None:
145
+ cfg = _make_cfg(args)
146
+ log_event(cfg, {"event": "oracle_started", "mode": "watch"})
147
+ print(f"AstroOracle watching {cfg.candidates_path}")
148
+
149
+ def _on_change():
150
+ try:
151
+ df = load_candidates(cfg)
152
+ if not df.empty:
153
+ top = select_candidates(df, cfg)
154
+ if not top.empty:
155
+ annotate_batch(cfg, top)
156
+ except Exception as e:
157
+ log_event(cfg, {"event": "error", "msg": str(e)})
158
+ print(f"Error: {e}")
159
+
160
+ watch_candidates(cfg.candidates_path, _on_change)
161
+
162
+
163
+ def cmd_batch_html(args: argparse.Namespace) -> None:
164
+ cfg = _make_cfg(args)
165
+ out_dir = Path(args.out_dir)
166
+ df = load_candidates(cfg)
167
+ if df.empty:
168
+ print("No candidates.")
169
+ return
170
+
171
+ cfg2 = OracleConfig(**{**cfg.__dict__, "n_query": int(args.n_query)})
172
+ top = select_candidates(df, cfg2)
173
+ if top.empty:
174
+ print("No selected candidates.")
175
+ return
176
+
177
+ generate_batch_html(cfg2, top, out_dir)
178
+ log_event(cfg2, {"event": "batch_html_generated", "out_dir": str(out_dir), "count": len(top)})
179
+ print(f"Batch HTML written to: {out_dir}/index.html")
180
+
181
+
182
+ def main() -> None:
183
+ p = argparse.ArgumentParser(prog="astrooracle")
184
+ sub = p.add_subparsers(dest="cmd", required=True)
185
+
186
+ def add_common(sp):
187
+ sp.add_argument("--candidates", default="candidates.parquet")
188
+ sp.add_argument("--annotations", default="annotations.csv")
189
+ sp.add_argument("--log", default="oracle_log.jsonl")
190
+ sp.add_argument("--retrain-script", default="scripts/retrain_model.py")
191
+ sp.add_argument("--model-path", default="best_model.pkl")
192
+ sp.add_argument("--min-new-labels", type=int, default=10)
193
+ sp.add_argument("--interval", type=int, default=300)
194
+ sp.add_argument("--n-query", type=int, default=6)
195
+ sp.add_argument("--pixels", type=int, default=400)
196
+ sp.add_argument("--cutout-radius-arcmin", type=float, default=None)
197
+ sp.add_argument("--survey", action="append", default=["DSS2 Red", "2MASS J"])
198
+ sp.add_argument("--no-gui", action="store_true")
199
+ sp.add_argument("--save-cutouts", default=None)
200
+
201
+ sp_run = sub.add_parser("run", help="Poll candidates file and annotate in CLI.")
202
+ add_common(sp_run)
203
+ sp_run.set_defaults(fn=cmd_run)
204
+
205
+ sp_watch = sub.add_parser("watch", help="Watch candidates file changes (watchdog).")
206
+ add_common(sp_watch)
207
+ sp_watch.set_defaults(fn=cmd_watch)
208
+
209
+ sp_html = sub.add_parser("batch-html", help="Generate static HTML batch annotator.")
210
+ add_common(sp_html)
211
+ sp_html.add_argument("--out-dir", required=True)
212
+ sp_html.set_defaults(fn=cmd_batch_html)
213
+
214
+ args = p.parse_args()
215
+ args.fn(args)
216
+
217
+
218
+ if __name__ == "__main__":
219
+ main()
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from astropy import units as u
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class OracleConfig:
11
+ candidates_path: Path
12
+ annot_path: Path
13
+ log_path: Path
14
+ retrain_script: Path
15
+ model_path: Path
16
+ min_new_labels_for_retrain: int
17
+ check_interval_s: int
18
+ surveys: list[str]
19
+ cutout_radius: u.Quantity
20
+ pixels: int
21
+ n_query: int
22
+ no_gui: bool
23
+ save_cutouts_dir: Path | None
24
+
25
+ @staticmethod
26
+ def default() -> OracleConfig:
27
+ return OracleConfig(
28
+ candidates_path=Path("candidates.parquet"),
29
+ annot_path=Path("annotations.csv"),
30
+ log_path=Path("oracle_log.jsonl"),
31
+ retrain_script=Path("scripts/retrain_model.py"),
32
+ model_path=Path("best_model.pkl"),
33
+ min_new_labels_for_retrain=10,
34
+ check_interval_s=300,
35
+ surveys=["DSS2 Red", "2MASS J"],
36
+ cutout_radius=0.15 * u.arcmin,
37
+ pixels=400,
38
+ n_query=6,
39
+ no_gui=False,
40
+ save_cutouts_dir=None,
41
+ )
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ from astropy import units as u
6
+ from astropy.coordinates import SkyCoord
7
+ from astroquery.skyview import SkyView
8
+ from sklearn.metrics import pairwise_distances
9
+
10
+ from .annotations import read_annotations
11
+ from .config import OracleConfig
12
+
13
+ REQUIRED_CAND_COLS = {"id", "ra", "dec", "anomaly_score"}
14
+
15
+
16
+ def load_candidates(cfg: OracleConfig) -> pd.DataFrame:
17
+ if not cfg.candidates_path.exists():
18
+ return pd.DataFrame()
19
+ df = pd.read_parquet(cfg.candidates_path)
20
+ missing = REQUIRED_CAND_COLS - set(df.columns)
21
+ if missing:
22
+ raise ValueError(f"Missing columns in candidates: {missing}")
23
+ return df
24
+
25
+
26
+ def select_candidates(df: pd.DataFrame, cfg: OracleConfig) -> pd.DataFrame:
27
+ df = df.copy()
28
+ median_score = float(df["anomaly_score"].median())
29
+ df["unc"] = np.abs(df["anomaly_score"] - median_score)
30
+
31
+ diversity = np.ones(len(df), dtype=float)
32
+ if "embedding" in df.columns and cfg.annot_path.exists():
33
+ annot = read_annotations(cfg)
34
+ if not annot.empty and "embedding_vec" in annot.columns:
35
+ ann_vecs = [v for v in annot["embedding_vec"].tolist() if isinstance(v, np.ndarray)]
36
+ if ann_vecs:
37
+ emb_all = np.stack(df["embedding"].values)
38
+ emb_ann = np.stack(ann_vecs)
39
+ dists = pairwise_distances(emb_all, emb_ann, metric="cosine")
40
+ diversity = dists.min(axis=1)
41
+ denom = (diversity.max() - diversity.min()) + 1e-8
42
+ diversity = (diversity - diversity.min()) / denom
43
+
44
+ unc_norm = df["unc"].to_numpy(dtype=float)
45
+ unc_norm = unc_norm / (float(unc_norm.max()) + 1e-8)
46
+
47
+ df["query_score"] = 0.6 * unc_norm + 0.4 * diversity
48
+ return df.nlargest(cfg.n_query, "query_score").reset_index(drop=True)
49
+
50
+
51
+ def fetch_cutouts(ra_deg: float, dec_deg: float, cfg: OracleConfig) -> list[tuple[str, np.ndarray]]:
52
+ coord = SkyCoord(ra=ra_deg * u.deg, dec=dec_deg * u.deg)
53
+ results: list[tuple[str, np.ndarray]] = []
54
+ for survey in cfg.surveys:
55
+ try:
56
+ images = SkyView.get_images(
57
+ position=coord,
58
+ survey=[survey],
59
+ pixels=cfg.pixels,
60
+ radius=cfg.cutout_radius,
61
+ )
62
+ if not images:
63
+ continue
64
+ data = images[0][0].data
65
+ if data is None:
66
+ continue
67
+ results.append((survey, data))
68
+ except Exception:
69
+ continue
70
+ return results
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .config import OracleConfig
10
+
11
+
12
+ def utcnow_iso() -> str:
13
+ return datetime.utcnow().isoformat()
14
+
15
+
16
+ def get_file_hash(path: Path) -> str:
17
+ if not path.exists():
18
+ return "no_file"
19
+ h = hashlib.sha256()
20
+ with path.open("rb") as f:
21
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
22
+ h.update(chunk)
23
+ return h.hexdigest()[:12]
24
+
25
+
26
+ def log_event(cfg: OracleConfig, event: dict[str, Any]) -> None:
27
+ payload = {
28
+ **event,
29
+ "timestamp": utcnow_iso(),
30
+ "model_version": get_file_hash(cfg.model_path),
31
+ "candidates_hash": get_file_hash(cfg.candidates_path),
32
+ }
33
+ cfg.log_path.parent.mkdir(parents=True, exist_ok=True)
34
+ with cfg.log_path.open("a", encoding="utf-8") as f:
35
+ f.write(json.dumps(payload, ensure_ascii=False) + "\n")
File without changes
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ try:
11
+ import ipywidgets as w
12
+ from IPython.display import clear_output, display
13
+ except Exception: # pragma: no cover
14
+ w = None # type: ignore
15
+ display = None # type: ignore
16
+ clear_output = None # type: ignore
17
+
18
+ from .config import OracleConfig
19
+
20
+ LABELS = [
21
+ ("Réel", "real_anomaly"),
22
+ ("Artefact", "artefact"),
23
+ ("Connu", "known"),
24
+ ("Nouveau type", "new_type"),
25
+ ("Skip", "skip"),
26
+ ]
27
+
28
+
29
+ class OracleUI:
30
+ def __init__(
31
+ self,
32
+ cfg: OracleConfig,
33
+ load_candidates_fn: Callable[[OracleConfig], pd.DataFrame],
34
+ select_candidates_fn: Callable[[pd.DataFrame, OracleConfig], pd.DataFrame],
35
+ fetch_cutouts_fn: Callable[[float, float, OracleConfig], Any],
36
+ render_fn: Callable[[Any, str], None],
37
+ append_annotations_fn: Callable[[OracleConfig, list[dict[str, Any]]], None],
38
+ ):
39
+ if w is None:
40
+ raise RuntimeError(
41
+ "ipywidgets not installed. Install extras: pip install -e '.[notebook]'"
42
+ )
43
+
44
+ self.cfg = cfg
45
+ self.load_candidates = load_candidates_fn
46
+ self.select_candidates = select_candidates_fn
47
+ self.fetch_cutouts = fetch_cutouts_fn
48
+ self.render = render_fn
49
+ self.append_annotations = append_annotations_fn
50
+
51
+ self.batch = pd.DataFrame()
52
+ self.idx = 0
53
+
54
+ self.title = w.HTML("")
55
+ self.comment = w.Textarea(
56
+ description="Commentaire", layout=w.Layout(width="100%", height="80px")
57
+ )
58
+ self.next_btn = w.Button(description="Next")
59
+ self.reload_btn = w.Button(description="Reload candidates", button_style="info")
60
+ self.out = w.Output()
61
+
62
+ self.label_buttons = []
63
+ for txt, lab in LABELS:
64
+ b = w.Button(description=txt)
65
+ b.on_click(lambda _, lab=lab: self.on_label(lab))
66
+ self.label_buttons.append(b)
67
+
68
+ self.next_btn.on_click(lambda _: self.on_next())
69
+ self.reload_btn.on_click(lambda _: self.on_reload())
70
+
71
+ controls = w.HBox([*self.label_buttons, self.next_btn, self.reload_btn])
72
+ display(w.VBox([self.title, self.comment, controls, self.out]))
73
+
74
+ self.on_reload()
75
+
76
+ def on_reload(self):
77
+ df = self.load_candidates(self.cfg)
78
+ if df.empty:
79
+ self.batch = pd.DataFrame()
80
+ self.idx = 0
81
+ with self.out:
82
+ clear_output()
83
+ print("Aucun candidat.")
84
+ return
85
+ self.batch = self.select_candidates(df, self.cfg)
86
+ self.idx = 0
87
+ self.show_current()
88
+
89
+ def show_current(self):
90
+ if self.batch.empty or self.idx >= len(self.batch):
91
+ with self.out:
92
+ clear_output()
93
+ print("Batch terminé.")
94
+ return
95
+
96
+ row = self.batch.iloc[self.idx]
97
+ title = (
98
+ f"ID: {row['id']} | score: {float(row['anomaly_score']):.4f} | "
99
+ f"RA: {float(row['ra']):.5f} Dec: {float(row['dec']):.5f}"
100
+ )
101
+ self.title.value = f"<b>{title}</b>"
102
+
103
+ cutouts = self.fetch_cutouts(float(row["ra"]), float(row["dec"]), self.cfg)
104
+ with self.out:
105
+ clear_output()
106
+ self.render(cutouts, title)
107
+
108
+ def on_label(self, label: str):
109
+ if label == "skip":
110
+ self.on_next()
111
+ return
112
+
113
+ row = self.batch.iloc[self.idx]
114
+ entry: dict[str, Any] = {
115
+ "id": row["id"],
116
+ "ra": float(row["ra"]),
117
+ "dec": float(row["dec"]),
118
+ "anomaly_score": float(row["anomaly_score"]),
119
+ "label": label,
120
+ "comment": self.comment.value.strip(),
121
+ "annotated_at": pd.Timestamp.utcnow().isoformat(),
122
+ }
123
+ if "embedding" in self.batch.columns and row.get("embedding", None) is not None:
124
+ entry["embedding"] = json.dumps(np.asarray(row["embedding"], dtype=float).tolist())
125
+
126
+ self.append_annotations(self.cfg, [entry])
127
+ self.comment.value = ""
128
+ self.on_next()
129
+
130
+ def on_next(self):
131
+ self.idx += 1
132
+ self.show_current()
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np
7
+ from astropy.visualization import ImageNormalize, ZScaleInterval
8
+
9
+ from .config import OracleConfig
10
+
11
+
12
+ def render_cutouts_matplotlib(
13
+ cutouts: list[tuple[str, np.ndarray]],
14
+ title: str,
15
+ cfg: OracleConfig,
16
+ save_path: Path | None = None,
17
+ ) -> None:
18
+ if not cutouts:
19
+ print("No cutouts available.")
20
+ return
21
+
22
+ n = len(cutouts)
23
+ fig, axes = plt.subplots(1, n, figsize=(4 * n, 4))
24
+ if n == 1:
25
+ axes = [axes]
26
+
27
+ for ax, (survey, data) in zip(axes, cutouts, strict=False):
28
+ norm = ImageNormalize(data, interval=ZScaleInterval())
29
+ ax.imshow(data, cmap="gray", norm=norm, origin="lower")
30
+ ax.set_title(survey, fontsize=9)
31
+ ax.axis("off")
32
+
33
+ fig.suptitle(title, fontsize=10)
34
+ plt.tight_layout()
35
+
36
+ if save_path is not None:
37
+ save_path.parent.mkdir(parents=True, exist_ok=True)
38
+ fig.savefig(save_path, dpi=150)
39
+
40
+ if not cfg.no_gui:
41
+ plt.show(block=False)
42
+ plt.pause(0.25)
43
+
44
+ plt.close(fig)
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+ try:
6
+ import plotly.express as px
7
+ except Exception: # pragma: no cover
8
+ px = None # type: ignore
9
+
10
+
11
+ def render_cutouts_plotly(cutouts: list[tuple[str, np.ndarray]], title: str) -> None:
12
+ if px is None:
13
+ raise RuntimeError("Plotly not installed. Install extras: pip install -e '.[plotly]'")
14
+ for survey, data in cutouts:
15
+ arr = np.asarray(data, dtype=float)
16
+ fig = px.imshow(arr, origin="lower", aspect="equal", title=f"{title} | {survey}")
17
+ fig.update_layout(margin=dict(l=10, r=10, t=40, b=10))
18
+ fig.update_xaxes(showticklabels=False)
19
+ fig.update_yaxes(showticklabels=False)
20
+ fig.show()
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+
7
+ try:
8
+ from watchdog.events import FileSystemEventHandler
9
+ from watchdog.observers import Observer
10
+ except Exception: # pragma: no cover
11
+ Observer = None # type: ignore
12
+ FileSystemEventHandler = object # type: ignore
13
+
14
+
15
+ class CandidatesHandler(FileSystemEventHandler):
16
+ def __init__(self, candidates_path: Path, on_change: Callable[[], None]):
17
+ self.candidates_path = candidates_path.resolve()
18
+ self.on_change = on_change
19
+
20
+ def on_modified(self, event):
21
+ if getattr(event, "is_directory", False):
22
+ return
23
+ src = Path(getattr(event, "src_path", ""))
24
+ if src and src.resolve() == self.candidates_path:
25
+ self.on_change()
26
+
27
+ def on_created(self, event):
28
+ self.on_modified(event)
29
+
30
+ def on_moved(self, event):
31
+ dst = Path(getattr(event, "dest_path", ""))
32
+ if dst and dst.resolve() == self.candidates_path:
33
+ self.on_change()
34
+
35
+
36
+ def watch_candidates(candidates_path: Path, on_change: Callable[[], None]) -> None:
37
+ if Observer is None:
38
+ raise RuntimeError("watchdog not installed. Install extras: pip install -e '.[watch]'")
39
+
40
+ handler = CandidatesHandler(candidates_path, on_change)
41
+ obs = Observer()
42
+ obs.schedule(handler, str(candidates_path.parent), recursive=False)
43
+ obs.start()
44
+ try:
45
+ while True:
46
+ time.sleep(0.5)
47
+ except KeyboardInterrupt:
48
+ pass
49
+ finally:
50
+ obs.stop()
51
+ obs.join()
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: astrooracle
3
+ Version: 0.1.0
4
+ Summary: AstroOracle: active learning oracle for astronomical anomaly triage (SkyView cutouts + annotations + retrain hooks).
5
+ Author: AstroOracle Contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/<OWNER>/AstroOracle
8
+ Project-URL: Issues, https://github.com/<OWNER>/AstroOracle/issues
9
+ Keywords: astronomy,anomaly-detection,active-learning,skyview,astropy
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: numpy>=1.23
23
+ Requires-Dist: pandas>=2.0
24
+ Requires-Dist: matplotlib>=3.7
25
+ Requires-Dist: astropy>=5.3
26
+ Requires-Dist: astroquery>=0.4.6
27
+ Requires-Dist: scikit-learn>=1.3
28
+ Requires-Dist: pyarrow>=12.0
29
+ Requires-Dist: pillow>=10.0
30
+ Provides-Extra: notebook
31
+ Requires-Dist: ipywidgets>=8.1; extra == "notebook"
32
+ Requires-Dist: jupyterlab>=4.0; extra == "notebook"
33
+ Requires-Dist: plotly>=5.18; extra == "notebook"
34
+ Provides-Extra: plotly
35
+ Requires-Dist: plotly>=5.18; extra == "plotly"
36
+ Provides-Extra: watch
37
+ Requires-Dist: watchdog>=3.0; extra == "watch"
38
+ Provides-Extra: dev
39
+ Requires-Dist: pytest<10,>=8; extra == "dev"
40
+ Requires-Dist: pytest-cov<8,>=5; extra == "dev"
41
+ Requires-Dist: coverage[toml]<8,>=7; extra == "dev"
42
+ Requires-Dist: ruff<1.0,>=0.6; extra == "dev"
43
+ Requires-Dist: black<27,>=24; extra == "dev"
44
+ Requires-Dist: pre-commit<5,>=3.7; extra == "dev"
45
+ Requires-Dist: mypy<2,>=1.10; extra == "dev"
46
+ Requires-Dist: types-requests>=2.32.0.20240914; extra == "dev"
47
+ Requires-Dist: build<2,>=1.2; extra == "dev"
48
+ Requires-Dist: twine<7,>=5; extra == "dev"
49
+ Requires-Dist: nox>=2024.4.15; extra == "dev"
50
+ Dynamic: license-file
51
+
52
+ # AstroOracle
53
+
54
+ AstroOracle is an active learning “oracle” for astronomical anomaly triage.
55
+
56
+ - Reads candidate detections from a Parquet file.
57
+ - Ranks them by uncertainty + optional embedding diversity.
58
+ - Pulls multi-survey cutouts from SkyView.
59
+ - Annotates via CLI (matplotlib) or Jupyter (ipywidgets + Plotly).
60
+ - Logs events in append-only JSONL.
61
+ - Optionally triggers retraining.
62
+
63
+ ## Install (editable)
64
+
65
+ ```bash
66
+ python -m venv .venv
67
+ source .venv/bin/activate
68
+ pip install -U pip
69
+ pip install -e ".[plotly,watch,notebook]"
70
+ ```
71
+
72
+ ## Quickstart (CLI)
73
+
74
+ Generate sample candidates:
75
+
76
+ ```bash
77
+ python examples/sample_candidates.py
78
+ ```
79
+
80
+ Run oracle:
81
+
82
+ ```bash
83
+ astrooracle run --candidates candidates.parquet --interval 300 --n-query 6
84
+ ```
85
+
86
+ Headless + PNG export:
87
+
88
+ ```bash
89
+ astrooracle run --no-gui --save-cutouts cutouts/ --candidates candidates.parquet
90
+ ```
91
+
92
+ ## Jupyter UI
93
+
94
+ ```bash
95
+ jupyter lab
96
+ ```
97
+
98
+ Open `examples/oracle_notebook.ipynb`.
99
+
100
+ ## Watch mode (watchdog)
101
+
102
+ ```bash
103
+ astrooracle watch --candidates candidates.parquet
104
+ ```
105
+
106
+ ## Batch HTML export
107
+
108
+ ```bash
109
+ astrooracle batch-html --candidates candidates.parquet --out-dir batch_out/ --n-query 60
110
+ ```
111
+
112
+ Open `batch_out/index.html` and export annotations from the page.
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,22 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/astrooracle/__init__.py
5
+ src/astrooracle/annotations.py
6
+ src/astrooracle/batch_html.py
7
+ src/astrooracle/cli.py
8
+ src/astrooracle/config.py
9
+ src/astrooracle/core.py
10
+ src/astrooracle/logging_utils.py
11
+ src/astrooracle/py.typed
12
+ src/astrooracle/ui_notebook.py
13
+ src/astrooracle/viz_matplotlib.py
14
+ src/astrooracle/viz_plotly.py
15
+ src/astrooracle/watch.py
16
+ src/astrooracle.egg-info/PKG-INFO
17
+ src/astrooracle.egg-info/SOURCES.txt
18
+ src/astrooracle.egg-info/dependency_links.txt
19
+ src/astrooracle.egg-info/entry_points.txt
20
+ src/astrooracle.egg-info/requires.txt
21
+ src/astrooracle.egg-info/top_level.txt
22
+ tests/test_select.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ astrooracle = astrooracle.cli:main
@@ -0,0 +1,32 @@
1
+ numpy>=1.23
2
+ pandas>=2.0
3
+ matplotlib>=3.7
4
+ astropy>=5.3
5
+ astroquery>=0.4.6
6
+ scikit-learn>=1.3
7
+ pyarrow>=12.0
8
+ pillow>=10.0
9
+
10
+ [dev]
11
+ pytest<10,>=8
12
+ pytest-cov<8,>=5
13
+ coverage[toml]<8,>=7
14
+ ruff<1.0,>=0.6
15
+ black<27,>=24
16
+ pre-commit<5,>=3.7
17
+ mypy<2,>=1.10
18
+ types-requests>=2.32.0.20240914
19
+ build<2,>=1.2
20
+ twine<7,>=5
21
+ nox>=2024.4.15
22
+
23
+ [notebook]
24
+ ipywidgets>=8.1
25
+ jupyterlab>=4.0
26
+ plotly>=5.18
27
+
28
+ [plotly]
29
+ plotly>=5.18
30
+
31
+ [watch]
32
+ watchdog>=3.0
@@ -0,0 +1 @@
1
+ astrooracle
@@ -0,0 +1,21 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ from astrooracle.config import OracleConfig
5
+ from astrooracle.core import select_candidates
6
+
7
+
8
+ def test_select_candidates_basic():
9
+ cfg = OracleConfig.default()
10
+ cfg = OracleConfig(**{**cfg.__dict__, "n_query": 5})
11
+ df = pd.DataFrame(
12
+ {
13
+ "id": [f"a{i}" for i in range(10)],
14
+ "ra": np.linspace(0, 1, 10),
15
+ "dec": np.linspace(0, 1, 10),
16
+ "anomaly_score": np.linspace(-2, 2, 10),
17
+ }
18
+ )
19
+ top = select_candidates(df, cfg)
20
+ assert len(top) == 5
21
+ assert "query_score" in top.columns