monohunter 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.
Potentially problematic release.
This version of monohunter might be problematic. Click here for more details.
- monohunter-0.1.0/LICENSE +21 -0
- monohunter-0.1.0/PKG-INFO +91 -0
- monohunter-0.1.0/README.md +72 -0
- monohunter-0.1.0/monohunter/__init__.py +3 -0
- monohunter-0.1.0/monohunter/cli.py +69 -0
- monohunter-0.1.0/monohunter/crossmatch.py +33 -0
- monohunter-0.1.0/monohunter/detect/__init__.py +6 -0
- monohunter-0.1.0/monohunter/detect/base.py +34 -0
- monohunter-0.1.0/monohunter/detect/box.py +92 -0
- monohunter-0.1.0/monohunter/detrend.py +30 -0
- monohunter-0.1.0/monohunter/fetch.py +83 -0
- monohunter-0.1.0/monohunter/pipeline.py +89 -0
- monohunter-0.1.0/monohunter/record.py +37 -0
- monohunter-0.1.0/monohunter.egg-info/PKG-INFO +91 -0
- monohunter-0.1.0/monohunter.egg-info/SOURCES.txt +24 -0
- monohunter-0.1.0/monohunter.egg-info/dependency_links.txt +1 -0
- monohunter-0.1.0/monohunter.egg-info/entry_points.txt +2 -0
- monohunter-0.1.0/monohunter.egg-info/requires.txt +10 -0
- monohunter-0.1.0/monohunter.egg-info/top_level.txt +1 -0
- monohunter-0.1.0/pyproject.toml +32 -0
- monohunter-0.1.0/setup.cfg +4 -0
- monohunter-0.1.0/tests/test_detect_box.py +59 -0
- monohunter-0.1.0/tests/test_detrend.py +34 -0
- monohunter-0.1.0/tests/test_fetch.py +38 -0
- monohunter-0.1.0/tests/test_pipeline.py +114 -0
- monohunter-0.1.0/tests/test_record.py +57 -0
monohunter-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stefano Rizzello
|
|
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,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: monohunter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find single long-period (mono-)transits in public TESS light curves that periodic pipelines under-find.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: numpy
|
|
10
|
+
Requires-Dist: scipy
|
|
11
|
+
Requires-Dist: matplotlib
|
|
12
|
+
Requires-Dist: pydantic>=2
|
|
13
|
+
Requires-Dist: lightkurve>=2.5
|
|
14
|
+
Requires-Dist: wotan
|
|
15
|
+
Requires-Dist: astroquery
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# monohunter
|
|
21
|
+
|
|
22
|
+
Find single long-period **mono-transits** in public TESS light curves — the
|
|
23
|
+
single-transit events that periodic pipelines (SPOC/QLP, which fold on a period)
|
|
24
|
+
structurally under-find. Built so many people can each search under-covered
|
|
25
|
+
targets and combine machine-readable finds.
|
|
26
|
+
|
|
27
|
+
Status: early. Detection core (P1) done and tested; CLI + packaging in progress.
|
|
28
|
+
|
|
29
|
+
## Why
|
|
30
|
+
|
|
31
|
+
Automated TESS pipelines run periodic searches (BLS/TLS) over every target.
|
|
32
|
+
A single transit has no period to fold on, so those searches miss it. Real
|
|
33
|
+
long-period planets have been co-discovered exactly here (e.g. TOI-2180 b, found
|
|
34
|
+
from one ~24-hour transit). monohunter targets that gap.
|
|
35
|
+
|
|
36
|
+
## How it works
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
fetch (dedup sectors, prefer 2-min, stream) ->
|
|
40
|
+
detrend (wotan, window >> transit or the dip gets eaten) ->
|
|
41
|
+
detect (matched-filter box scan, non-periodic) ->
|
|
42
|
+
FindRecord (versioned + validated JSON) -> candidates/
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The `Detector` interface is a seam: v1 is a matched-filter box scan; a
|
|
46
|
+
GP-based detector (`nuance`) plugs in later without touching the pipeline.
|
|
47
|
+
The versioned `FindRecord` JSON is the contract a future aggregation server
|
|
48
|
+
consumes.
|
|
49
|
+
|
|
50
|
+
## Reuse, not reinvention
|
|
51
|
+
|
|
52
|
+
Stands on [lightkurve](https://docs.lightkurve.org),
|
|
53
|
+
[wotan](https://github.com/hippke/wotan), scipy, and astroquery. monohunter is
|
|
54
|
+
orchestration + the single-transit gap + result aggregation, not a new detection
|
|
55
|
+
engine.
|
|
56
|
+
|
|
57
|
+
## Dev
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
python -m venv .venv && . .venv/Scripts/activate # Windows
|
|
61
|
+
pip install -e ".[dev]"
|
|
62
|
+
pytest -q
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Releasing to PyPI
|
|
66
|
+
|
|
67
|
+
CI (`.github/workflows/ci.yml`) runs the tests on every push. Publishing
|
|
68
|
+
(`.github/workflows/release.yml`) fires on a version tag and uses **Trusted
|
|
69
|
+
Publishing** — no token in GitHub.
|
|
70
|
+
|
|
71
|
+
One-time PyPI setup (before the first release):
|
|
72
|
+
|
|
73
|
+
1. On PyPI: Account → Publishing → **Add a pending publisher**:
|
|
74
|
+
- PyPI project name: `monohunter`
|
|
75
|
+
- Owner: `Rinkia` · Repository: `monohunter`
|
|
76
|
+
- Workflow: `release.yml` · Environment: leave blank (Any)
|
|
77
|
+
2. (Optional) For a manual approval gate, create a GitHub Environment, set it
|
|
78
|
+
as the pending-publisher Environment, and add `environment: <name>` back to
|
|
79
|
+
the `publish` job in `release.yml`.
|
|
80
|
+
|
|
81
|
+
Then release:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# bump version in pyproject.toml first
|
|
85
|
+
git tag v0.1.0
|
|
86
|
+
git push --tags
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# monohunter
|
|
2
|
+
|
|
3
|
+
Find single long-period **mono-transits** in public TESS light curves — the
|
|
4
|
+
single-transit events that periodic pipelines (SPOC/QLP, which fold on a period)
|
|
5
|
+
structurally under-find. Built so many people can each search under-covered
|
|
6
|
+
targets and combine machine-readable finds.
|
|
7
|
+
|
|
8
|
+
Status: early. Detection core (P1) done and tested; CLI + packaging in progress.
|
|
9
|
+
|
|
10
|
+
## Why
|
|
11
|
+
|
|
12
|
+
Automated TESS pipelines run periodic searches (BLS/TLS) over every target.
|
|
13
|
+
A single transit has no period to fold on, so those searches miss it. Real
|
|
14
|
+
long-period planets have been co-discovered exactly here (e.g. TOI-2180 b, found
|
|
15
|
+
from one ~24-hour transit). monohunter targets that gap.
|
|
16
|
+
|
|
17
|
+
## How it works
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
fetch (dedup sectors, prefer 2-min, stream) ->
|
|
21
|
+
detrend (wotan, window >> transit or the dip gets eaten) ->
|
|
22
|
+
detect (matched-filter box scan, non-periodic) ->
|
|
23
|
+
FindRecord (versioned + validated JSON) -> candidates/
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The `Detector` interface is a seam: v1 is a matched-filter box scan; a
|
|
27
|
+
GP-based detector (`nuance`) plugs in later without touching the pipeline.
|
|
28
|
+
The versioned `FindRecord` JSON is the contract a future aggregation server
|
|
29
|
+
consumes.
|
|
30
|
+
|
|
31
|
+
## Reuse, not reinvention
|
|
32
|
+
|
|
33
|
+
Stands on [lightkurve](https://docs.lightkurve.org),
|
|
34
|
+
[wotan](https://github.com/hippke/wotan), scipy, and astroquery. monohunter is
|
|
35
|
+
orchestration + the single-transit gap + result aggregation, not a new detection
|
|
36
|
+
engine.
|
|
37
|
+
|
|
38
|
+
## Dev
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python -m venv .venv && . .venv/Scripts/activate # Windows
|
|
42
|
+
pip install -e ".[dev]"
|
|
43
|
+
pytest -q
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Releasing to PyPI
|
|
47
|
+
|
|
48
|
+
CI (`.github/workflows/ci.yml`) runs the tests on every push. Publishing
|
|
49
|
+
(`.github/workflows/release.yml`) fires on a version tag and uses **Trusted
|
|
50
|
+
Publishing** — no token in GitHub.
|
|
51
|
+
|
|
52
|
+
One-time PyPI setup (before the first release):
|
|
53
|
+
|
|
54
|
+
1. On PyPI: Account → Publishing → **Add a pending publisher**:
|
|
55
|
+
- PyPI project name: `monohunter`
|
|
56
|
+
- Owner: `Rinkia` · Repository: `monohunter`
|
|
57
|
+
- Workflow: `release.yml` · Environment: leave blank (Any)
|
|
58
|
+
2. (Optional) For a manual approval gate, create a GitHub Environment, set it
|
|
59
|
+
as the pending-publisher Environment, and add `environment: <name>` back to
|
|
60
|
+
the `publish` job in `release.yml`.
|
|
61
|
+
|
|
62
|
+
Then release:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# bump version in pyproject.toml first
|
|
66
|
+
git tag v0.1.0
|
|
67
|
+
git push --tags
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""T6 — CLI: `monohunter run --tic <id>`.
|
|
2
|
+
|
|
3
|
+
End-to-end: search TESS -> detrend -> detect single transits -> write one JSON
|
|
4
|
+
find-record + PNG per candidate into the output dir.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .pipeline import run_target
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(argv: list[str] | None = None) -> int:
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="monohunter",
|
|
21
|
+
description="Hunt single long-period (mono-)transits in public TESS light curves.",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("--version", action="version", version=f"monohunter {__version__}")
|
|
24
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
25
|
+
|
|
26
|
+
run = sub.add_parser("run", help="search one target by TIC id")
|
|
27
|
+
run.add_argument("--tic", type=int, required=True, help="TESS Input Catalog id")
|
|
28
|
+
run.add_argument("--window", type=float, default=3.0, help="detrend window in days (>> transit)")
|
|
29
|
+
run.add_argument("--outdir", default="candidates", help="where to write JSON + PNG")
|
|
30
|
+
run.add_argument("--no-plot", action="store_true", help="skip PNG generation")
|
|
31
|
+
run.add_argument(
|
|
32
|
+
"--sectors",
|
|
33
|
+
type=int,
|
|
34
|
+
nargs="+",
|
|
35
|
+
default=None,
|
|
36
|
+
help="restrict to these sector numbers (default: all available)",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
args = parser.parse_args(argv)
|
|
40
|
+
|
|
41
|
+
if args.cmd == "run":
|
|
42
|
+
records = run_target(
|
|
43
|
+
args.tic,
|
|
44
|
+
window_length=args.window,
|
|
45
|
+
outdir=args.outdir,
|
|
46
|
+
make_plots=not args.no_plot,
|
|
47
|
+
sectors=args.sectors,
|
|
48
|
+
)
|
|
49
|
+
if not records:
|
|
50
|
+
print(f"No candidates for TIC {args.tic} (nothing above SNR threshold).")
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
os.makedirs(args.outdir, exist_ok=True)
|
|
54
|
+
for rec in records:
|
|
55
|
+
path = os.path.join(args.outdir, f"tic{rec.tic}_s{rec.sector}.json")
|
|
56
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
57
|
+
fh.write(rec.to_json(indent=2))
|
|
58
|
+
flag = f" [known {rec.known_toi_id}]" if rec.known_toi_match else " [not a known TOI]"
|
|
59
|
+
print(
|
|
60
|
+
f"S{rec.sector}: depth={rec.depth_ppt:.2f}ppt "
|
|
61
|
+
f"dur={rec.duration_hr:.0f}h SNR={rec.snr:.1f}{flag} -> {path}"
|
|
62
|
+
)
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
return 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
sys.exit(main())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""T7 — known-TOI cross-match.
|
|
2
|
+
|
|
3
|
+
Flags whether a target is already a known TESS Object of Interest, so a candidate
|
|
4
|
+
the tool surfaces is auto-labeled "already known" vs potentially new. Queries the
|
|
5
|
+
NASA Exoplanet Archive TOI table. Network-optional: any failure degrades to
|
|
6
|
+
"unknown" (False, None) so the tool still runs offline.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
_CACHE: dict[int, tuple[bool, str | None]] = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def known_toi(tic: int) -> tuple[bool, str | None]:
|
|
15
|
+
"""Return (is_known_toi, toi_id_or_None). Cached per TIC; safe offline."""
|
|
16
|
+
tic = int(tic)
|
|
17
|
+
if tic in _CACHE:
|
|
18
|
+
return _CACHE[tic]
|
|
19
|
+
|
|
20
|
+
result: tuple[bool, str | None] = (False, None)
|
|
21
|
+
try:
|
|
22
|
+
from astroquery.ipac.nexsci.nasa_exoplanet_archive import (
|
|
23
|
+
NasaExoplanetArchive,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
table = NasaExoplanetArchive.query_criteria(table="toi", where=f"tid={tic}")
|
|
27
|
+
if len(table) > 0:
|
|
28
|
+
result = (True, f"TOI-{table['toi'][0]}")
|
|
29
|
+
except Exception:
|
|
30
|
+
result = (False, None) # network down / astroquery hiccup -> unknown
|
|
31
|
+
|
|
32
|
+
_CACHE[tic] = result
|
|
33
|
+
return result
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""T2 — the Detector seam (design decision 1D).
|
|
2
|
+
|
|
3
|
+
A Detector is PURE SIGNAL: it takes a detrended light curve (time, flux) and
|
|
4
|
+
returns candidate dips. It knows nothing about TIC ids, sectors, or JSON — the
|
|
5
|
+
pipeline assembles those into a FindRecord. This keeps the box detector, the
|
|
6
|
+
future nuance (GP) detector, and Swarm's server-side re-scoring all behind one
|
|
7
|
+
interface. Detection logic must NOT leak into the fetch or CLI layers.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Candidate:
|
|
20
|
+
"""A single dip found in a light curve. No target metadata — that's the pipeline's job."""
|
|
21
|
+
|
|
22
|
+
event_time_btjd: float
|
|
23
|
+
depth_ppt: float
|
|
24
|
+
duration_hr: float
|
|
25
|
+
snr: float
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Detector(ABC):
|
|
29
|
+
"""Search a detrended light curve for mono-transit candidates."""
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def search(self, time: np.ndarray, flux: np.ndarray) -> list[Candidate]:
|
|
33
|
+
"""Return candidates ordered best-first (highest SNR). Empty list = nothing found."""
|
|
34
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""T3 — matched-filter box scan (v1 detector).
|
|
2
|
+
|
|
3
|
+
Non-periodic by design: slides a box (uniform-mean) transit model of several
|
|
4
|
+
trial durations across the light curve and scores the deepest sustained dip by
|
|
5
|
+
SNR. No phase-folding, so a SINGLE transit is detectable — which is the whole
|
|
6
|
+
point (TLS can't do this; it needs a period to fold on).
|
|
7
|
+
|
|
8
|
+
flux ─┐ ┌───── baseline ≈ 1 (after normalize+detrend)
|
|
9
|
+
│ ┌───┐ │
|
|
10
|
+
└──┘ └──┘ one box, width = trial duration
|
|
11
|
+
▲
|
|
12
|
+
depth = 1 - mean_in_box
|
|
13
|
+
snr = depth / (sigma / sqrt(n_in)) sigma = robust MAD scatter
|
|
14
|
+
|
|
15
|
+
ponytail: returns the single best candidate. Multi-candidate / iterative masking
|
|
16
|
+
is a later upgrade once v1 finds things.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
from scipy.ndimage import uniform_filter1d
|
|
23
|
+
|
|
24
|
+
from .base import Candidate, Detector
|
|
25
|
+
|
|
26
|
+
# ponytail: fixed duration grid tuned for long/single transits (hours). Widen if
|
|
27
|
+
# you start hunting shorter events.
|
|
28
|
+
DEFAULT_DURATIONS_HR = (2.0, 4.0, 6.0, 8.0, 12.0, 18.0, 24.0, 30.0)
|
|
29
|
+
DEFAULT_SNR_THRESHOLD = 7.0 # SDE-like floor; below this is noise (see TLS best practice)
|
|
30
|
+
_MAD_TO_SIGMA = 1.4826 # MAD -> Gaussian sigma
|
|
31
|
+
# ponytail: trim this much from each end before searching. Start/end-of-sector
|
|
32
|
+
# ramps span hours-to-a-day and masquerade as transits; a box-width-only guard
|
|
33
|
+
# misses ramps wider than the smallest box. Widen if edge FPs persist.
|
|
34
|
+
EDGE_TRIM_D = 0.5
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BoxMatchedFilter(Detector):
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
durations_hr: tuple[float, ...] = DEFAULT_DURATIONS_HR,
|
|
41
|
+
snr_threshold: float = DEFAULT_SNR_THRESHOLD,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.durations_hr = durations_hr
|
|
44
|
+
self.snr_threshold = snr_threshold
|
|
45
|
+
|
|
46
|
+
def search(self, time: np.ndarray, flux: np.ndarray) -> list[Candidate]:
|
|
47
|
+
time = np.asarray(time, dtype=float)
|
|
48
|
+
flux = np.asarray(flux, dtype=float)
|
|
49
|
+
good = np.isfinite(time) & np.isfinite(flux)
|
|
50
|
+
time, flux = time[good], flux[good]
|
|
51
|
+
if time.size < 10:
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
dt = float(np.median(np.diff(time))) # days per cadence
|
|
55
|
+
if not np.isfinite(dt) or dt <= 0:
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
sigma = _MAD_TO_SIGMA * float(np.median(np.abs(flux - np.median(flux))))
|
|
59
|
+
if sigma <= 0:
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
best: Candidate | None = None
|
|
63
|
+
for dur_hr in self.durations_hr:
|
|
64
|
+
width = int(round((dur_hr / 24.0) / dt)) # cadences in the box
|
|
65
|
+
if width < 3 or width >= flux.size:
|
|
66
|
+
continue
|
|
67
|
+
rolling_mean = uniform_filter1d(flux, size=width, mode="nearest")
|
|
68
|
+
# Edge guard: a box centered at index i spans [i-width/2, i+width/2],
|
|
69
|
+
# so to keep the whole box clear of the trimmed edge zone (padding +
|
|
70
|
+
# start/end-of-sector ramps — the S26 TOI-2180 false positive) the
|
|
71
|
+
# center must sit at least EDGE_TRIM_D + half a box from each end.
|
|
72
|
+
edge = int(round(EDGE_TRIM_D / dt)) + width // 2
|
|
73
|
+
if 2 * edge >= rolling_mean.size:
|
|
74
|
+
continue
|
|
75
|
+
rolling_mean[:edge] = np.inf
|
|
76
|
+
rolling_mean[-edge:] = np.inf
|
|
77
|
+
i = int(np.argmin(rolling_mean))
|
|
78
|
+
depth = 1.0 - float(rolling_mean[i])
|
|
79
|
+
if depth <= 0:
|
|
80
|
+
continue
|
|
81
|
+
snr = depth / (sigma / np.sqrt(width))
|
|
82
|
+
if best is None or snr > best.snr:
|
|
83
|
+
best = Candidate(
|
|
84
|
+
event_time_btjd=float(time[i]),
|
|
85
|
+
depth_ppt=depth * 1e3,
|
|
86
|
+
duration_hr=float(dur_hr),
|
|
87
|
+
snr=float(snr),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if best is None or best.snr < self.snr_threshold:
|
|
91
|
+
return []
|
|
92
|
+
return [best]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""T5 — detrend wrapper (wotan).
|
|
2
|
+
|
|
3
|
+
Thin wrapper so the rest of the code depends on ONE detrend entry point. The only
|
|
4
|
+
real knob is `window_length` (days): it MUST be several times the transit
|
|
5
|
+
duration, or flattening eats the very dip you're hunting. That footgun has a
|
|
6
|
+
regression test (test_detrend.py) — do not remove it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from wotan import flatten as _wotan_flatten
|
|
13
|
+
|
|
14
|
+
DEFAULT_METHOD = "biweight"
|
|
15
|
+
DEFAULT_WINDOW_D = 3.0 # ponytail: 3x a ~1-day (24h) transit. Tune per target.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def flatten(
|
|
19
|
+
time: np.ndarray,
|
|
20
|
+
flux: np.ndarray,
|
|
21
|
+
method: str = DEFAULT_METHOD,
|
|
22
|
+
window_length: float = DEFAULT_WINDOW_D,
|
|
23
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
24
|
+
"""Return (flat_flux, trend). window_length is in DAYS."""
|
|
25
|
+
time = np.asarray(time, dtype=float)
|
|
26
|
+
flux = np.asarray(flux, dtype=float)
|
|
27
|
+
flat, trend = _wotan_flatten(
|
|
28
|
+
time, flux, method=method, window_length=window_length, return_trend=True
|
|
29
|
+
)
|
|
30
|
+
return flat, trend
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""T4 — sector resolution + streaming loader (design decision 3A + streaming).
|
|
2
|
+
|
|
3
|
+
Two jobs:
|
|
4
|
+
1. resolve_sectors(): dedup a search result to ONE row per sector, preferring
|
|
5
|
+
2-min (120s) SPOC over the 20-sec duplicate. The notebook's naive all-58
|
|
6
|
+
download is a footgun — many rows are the same sector at two cadences.
|
|
7
|
+
2. iter_lightcurves(): stream sectors one at a time so memory stays bounded to
|
|
8
|
+
~one light curve even on 58-sector targets. The downloader is injected so
|
|
9
|
+
this is unit-testable without hitting the network.
|
|
10
|
+
|
|
11
|
+
A "row" is any mapping with at least {"sector": int, "cadence_s": int}. In real
|
|
12
|
+
use these come from lightkurve's search table; in tests they're plain dicts.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any, Callable, Iterable, Iterator, Mapping, TypeVar
|
|
19
|
+
|
|
20
|
+
Row = Mapping[str, object]
|
|
21
|
+
LC = TypeVar("LC")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _sector_from_mission(value: object) -> int | None:
|
|
25
|
+
match = re.search(r"Sector\s+(\d+)", str(value))
|
|
26
|
+
return int(match.group(1)) if match else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _scalar(value: object) -> float:
|
|
30
|
+
return float(getattr(value, "value", value))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def search_tess(tic: int, author: str = "SPOC") -> tuple[Any, list[Row]]:
|
|
34
|
+
"""Search TESS light curves for a TIC. Returns (SearchResult, rows).
|
|
35
|
+
|
|
36
|
+
Prefers SPOC, falls back to QLP (FFI) if SPOC has nothing. Each row carries
|
|
37
|
+
the SearchResult index so the streaming loader can download it lazily.
|
|
38
|
+
Network call — not unit-tested; the CLI E2E exercises it live.
|
|
39
|
+
"""
|
|
40
|
+
import lightkurve as lk
|
|
41
|
+
|
|
42
|
+
sr = lk.search_lightcurve(f"TIC {int(tic)}", mission="TESS", author=author)
|
|
43
|
+
if len(sr) == 0:
|
|
44
|
+
sr = lk.search_lightcurve(f"TIC {int(tic)}", mission="TESS", author="QLP")
|
|
45
|
+
|
|
46
|
+
table = sr.table
|
|
47
|
+
rows: list[Row] = []
|
|
48
|
+
for i in range(len(sr)):
|
|
49
|
+
sector = _sector_from_mission(table["mission"][i])
|
|
50
|
+
if sector is None:
|
|
51
|
+
continue
|
|
52
|
+
cadence = int(round(_scalar(table["exptime"][i])))
|
|
53
|
+
rows.append({"sector": sector, "cadence_s": cadence, "_index": i})
|
|
54
|
+
return sr, rows
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _preference(cadence_s: int) -> tuple[int, int]:
|
|
58
|
+
"""Lower sorts first. 120s (2-min) wins; otherwise shorter cadence."""
|
|
59
|
+
return (0 if cadence_s == 120 else 1, int(cadence_s))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_sectors(rows: Iterable[Row]) -> list[Row]:
|
|
63
|
+
"""One row per sector, 2-min preferred, sorted by sector."""
|
|
64
|
+
by_sector: dict[int, Row] = {}
|
|
65
|
+
for row in rows:
|
|
66
|
+
sector = int(row["sector"]) # type: ignore[call-overload]
|
|
67
|
+
cadence = int(row["cadence_s"]) # type: ignore[call-overload]
|
|
68
|
+
current = by_sector.get(sector)
|
|
69
|
+
if current is None or _preference(cadence) < _preference(int(current["cadence_s"])): # type: ignore[call-overload]
|
|
70
|
+
by_sector[sector] = row
|
|
71
|
+
return [by_sector[s] for s in sorted(by_sector)]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def iter_lightcurves(
|
|
75
|
+
rows: Iterable[Row], download: Callable[[Row], LC]
|
|
76
|
+
) -> Iterator[tuple[Row, LC]]:
|
|
77
|
+
"""Yield (row, light_curve) one deduped sector at a time.
|
|
78
|
+
|
|
79
|
+
The caller runs the detector on each and keeps only the find-record, so the
|
|
80
|
+
light curve is released before the next download — bounded memory.
|
|
81
|
+
"""
|
|
82
|
+
for row in resolve_sectors(rows):
|
|
83
|
+
yield row, download(row)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Pipeline — glue fetch -> detrend -> detect -> FindRecord for one target.
|
|
2
|
+
|
|
3
|
+
Streams sectors one at a time (bounded memory), runs the detector on each
|
|
4
|
+
detrended light curve, and assembles a validated FindRecord per candidate with
|
|
5
|
+
a diagnostic PNG. This is the orchestration layer the CLI calls.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
import matplotlib
|
|
13
|
+
|
|
14
|
+
matplotlib.use("Agg") # headless: save PNGs, never open a window
|
|
15
|
+
import matplotlib.pyplot as plt
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .crossmatch import known_toi
|
|
20
|
+
from .detect import BoxMatchedFilter, Detector
|
|
21
|
+
from .detrend import DEFAULT_METHOD, DEFAULT_WINDOW_D, flatten
|
|
22
|
+
from .fetch import iter_lightcurves, search_tess
|
|
23
|
+
from .record import FindRecord
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _values(array: object) -> np.ndarray:
|
|
27
|
+
return np.asarray(getattr(array, "value", array), dtype=float)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _save_plot(outdir: str, rec: FindRecord, time: np.ndarray, flux: np.ndarray) -> str:
|
|
31
|
+
os.makedirs(outdir, exist_ok=True)
|
|
32
|
+
fig, ax = plt.subplots(figsize=(10, 4))
|
|
33
|
+
ax.scatter(time, flux, s=1)
|
|
34
|
+
ax.axvline(rec.event_time_btjd, color="red", lw=1)
|
|
35
|
+
ax.set_xlabel("Time [BTJD]")
|
|
36
|
+
ax.set_ylabel("flattened flux")
|
|
37
|
+
ax.set_title(f"TIC {rec.tic} S{rec.sector} SNR={rec.snr:.1f}")
|
|
38
|
+
path = os.path.join(outdir, f"tic{rec.tic}_s{rec.sector}.png")
|
|
39
|
+
fig.savefig(path, dpi=100, bbox_inches="tight")
|
|
40
|
+
plt.close(fig)
|
|
41
|
+
return path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_target(
|
|
45
|
+
tic: int,
|
|
46
|
+
detector: Detector | None = None,
|
|
47
|
+
window_length: float = DEFAULT_WINDOW_D,
|
|
48
|
+
outdir: str = "candidates",
|
|
49
|
+
make_plots: bool = True,
|
|
50
|
+
sectors: list[int] | None = None,
|
|
51
|
+
) -> list[FindRecord]:
|
|
52
|
+
"""Search deduped sectors of one TIC; return validated candidate records.
|
|
53
|
+
|
|
54
|
+
sectors: restrict to these sector numbers (None = all available).
|
|
55
|
+
"""
|
|
56
|
+
detector = detector or BoxMatchedFilter()
|
|
57
|
+
sr, rows = search_tess(tic)
|
|
58
|
+
if sectors is not None:
|
|
59
|
+
wanted = set(sectors)
|
|
60
|
+
rows = [r for r in rows if int(r["sector"]) in wanted]
|
|
61
|
+
is_known, toi_id = known_toi(tic)
|
|
62
|
+
|
|
63
|
+
def download(row: dict) -> object:
|
|
64
|
+
return sr[row["_index"]].download().remove_nans().normalize()
|
|
65
|
+
|
|
66
|
+
records: list[FindRecord] = []
|
|
67
|
+
for row, lc in iter_lightcurves(rows, download):
|
|
68
|
+
time = _values(lc.time.value if hasattr(lc.time, "value") else lc.time)
|
|
69
|
+
flux = _values(lc.flux)
|
|
70
|
+
flat, _ = flatten(time, flux, window_length=window_length)
|
|
71
|
+
for cand in detector.search(time, flat):
|
|
72
|
+
rec = FindRecord(
|
|
73
|
+
tic=int(tic),
|
|
74
|
+
sector=int(row["sector"]),
|
|
75
|
+
cadence_s=int(row["cadence_s"]),
|
|
76
|
+
event_time_btjd=cand.event_time_btjd,
|
|
77
|
+
depth_ppt=cand.depth_ppt,
|
|
78
|
+
duration_hr=cand.duration_hr,
|
|
79
|
+
snr=cand.snr,
|
|
80
|
+
detrend_method=DEFAULT_METHOD,
|
|
81
|
+
detrend_window_d=window_length,
|
|
82
|
+
tool_version=__version__,
|
|
83
|
+
known_toi_match=is_known,
|
|
84
|
+
known_toi_id=toi_id,
|
|
85
|
+
)
|
|
86
|
+
if make_plots:
|
|
87
|
+
rec = rec.model_copy(update={"plot_path": _save_plot(outdir, rec, time, flat)})
|
|
88
|
+
records.append(rec)
|
|
89
|
+
return records
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""T1 — the find-record: MonoHunter's output unit and the Swarm aggregation contract.
|
|
2
|
+
|
|
3
|
+
Versioned + validated (design decision 2A). `schema_version` travels with every
|
|
4
|
+
record so a future Swarm server can migrate old contributor files. `extra="forbid"`
|
|
5
|
+
makes a typo'd or drifted field a hard error at write time, not a silent mismatch
|
|
6
|
+
that only surfaces when 100 people's JSON files fail to merge.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
12
|
+
|
|
13
|
+
SCHEMA_VERSION = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FindRecord(BaseModel):
|
|
17
|
+
"""One candidate mono-transit, ready to serialize to JSON."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(extra="forbid") # reject unknown/drifted fields
|
|
20
|
+
|
|
21
|
+
schema_version: int = SCHEMA_VERSION
|
|
22
|
+
tic: int = Field(..., description="TESS Input Catalog id of the target")
|
|
23
|
+
sector: int
|
|
24
|
+
cadence_s: int = Field(..., description="120 (2-min) or 20 (20-sec)")
|
|
25
|
+
event_time_btjd: float = Field(..., description="dip center, TESS BTJD")
|
|
26
|
+
depth_ppt: float = Field(..., ge=0, description="transit depth, parts per thousand")
|
|
27
|
+
duration_hr: float = Field(..., gt=0)
|
|
28
|
+
snr: float = Field(..., ge=0)
|
|
29
|
+
detrend_method: str
|
|
30
|
+
detrend_window_d: float = Field(..., gt=0)
|
|
31
|
+
tool_version: str
|
|
32
|
+
known_toi_match: bool = False
|
|
33
|
+
known_toi_id: str | None = None
|
|
34
|
+
plot_path: str | None = None
|
|
35
|
+
|
|
36
|
+
def to_json(self, **kwargs: object) -> str:
|
|
37
|
+
return self.model_dump_json(**kwargs) # type: ignore[arg-type]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: monohunter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find single long-period (mono-)transits in public TESS light curves that periodic pipelines under-find.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: numpy
|
|
10
|
+
Requires-Dist: scipy
|
|
11
|
+
Requires-Dist: matplotlib
|
|
12
|
+
Requires-Dist: pydantic>=2
|
|
13
|
+
Requires-Dist: lightkurve>=2.5
|
|
14
|
+
Requires-Dist: wotan
|
|
15
|
+
Requires-Dist: astroquery
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# monohunter
|
|
21
|
+
|
|
22
|
+
Find single long-period **mono-transits** in public TESS light curves — the
|
|
23
|
+
single-transit events that periodic pipelines (SPOC/QLP, which fold on a period)
|
|
24
|
+
structurally under-find. Built so many people can each search under-covered
|
|
25
|
+
targets and combine machine-readable finds.
|
|
26
|
+
|
|
27
|
+
Status: early. Detection core (P1) done and tested; CLI + packaging in progress.
|
|
28
|
+
|
|
29
|
+
## Why
|
|
30
|
+
|
|
31
|
+
Automated TESS pipelines run periodic searches (BLS/TLS) over every target.
|
|
32
|
+
A single transit has no period to fold on, so those searches miss it. Real
|
|
33
|
+
long-period planets have been co-discovered exactly here (e.g. TOI-2180 b, found
|
|
34
|
+
from one ~24-hour transit). monohunter targets that gap.
|
|
35
|
+
|
|
36
|
+
## How it works
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
fetch (dedup sectors, prefer 2-min, stream) ->
|
|
40
|
+
detrend (wotan, window >> transit or the dip gets eaten) ->
|
|
41
|
+
detect (matched-filter box scan, non-periodic) ->
|
|
42
|
+
FindRecord (versioned + validated JSON) -> candidates/
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The `Detector` interface is a seam: v1 is a matched-filter box scan; a
|
|
46
|
+
GP-based detector (`nuance`) plugs in later without touching the pipeline.
|
|
47
|
+
The versioned `FindRecord` JSON is the contract a future aggregation server
|
|
48
|
+
consumes.
|
|
49
|
+
|
|
50
|
+
## Reuse, not reinvention
|
|
51
|
+
|
|
52
|
+
Stands on [lightkurve](https://docs.lightkurve.org),
|
|
53
|
+
[wotan](https://github.com/hippke/wotan), scipy, and astroquery. monohunter is
|
|
54
|
+
orchestration + the single-transit gap + result aggregation, not a new detection
|
|
55
|
+
engine.
|
|
56
|
+
|
|
57
|
+
## Dev
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
python -m venv .venv && . .venv/Scripts/activate # Windows
|
|
61
|
+
pip install -e ".[dev]"
|
|
62
|
+
pytest -q
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Releasing to PyPI
|
|
66
|
+
|
|
67
|
+
CI (`.github/workflows/ci.yml`) runs the tests on every push. Publishing
|
|
68
|
+
(`.github/workflows/release.yml`) fires on a version tag and uses **Trusted
|
|
69
|
+
Publishing** — no token in GitHub.
|
|
70
|
+
|
|
71
|
+
One-time PyPI setup (before the first release):
|
|
72
|
+
|
|
73
|
+
1. On PyPI: Account → Publishing → **Add a pending publisher**:
|
|
74
|
+
- PyPI project name: `monohunter`
|
|
75
|
+
- Owner: `Rinkia` · Repository: `monohunter`
|
|
76
|
+
- Workflow: `release.yml` · Environment: leave blank (Any)
|
|
77
|
+
2. (Optional) For a manual approval gate, create a GitHub Environment, set it
|
|
78
|
+
as the pending-publisher Environment, and add `environment: <name>` back to
|
|
79
|
+
the `publish` job in `release.yml`.
|
|
80
|
+
|
|
81
|
+
Then release:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# bump version in pyproject.toml first
|
|
85
|
+
git tag v0.1.0
|
|
86
|
+
git push --tags
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
monohunter/__init__.py
|
|
5
|
+
monohunter/cli.py
|
|
6
|
+
monohunter/crossmatch.py
|
|
7
|
+
monohunter/detrend.py
|
|
8
|
+
monohunter/fetch.py
|
|
9
|
+
monohunter/pipeline.py
|
|
10
|
+
monohunter/record.py
|
|
11
|
+
monohunter.egg-info/PKG-INFO
|
|
12
|
+
monohunter.egg-info/SOURCES.txt
|
|
13
|
+
monohunter.egg-info/dependency_links.txt
|
|
14
|
+
monohunter.egg-info/entry_points.txt
|
|
15
|
+
monohunter.egg-info/requires.txt
|
|
16
|
+
monohunter.egg-info/top_level.txt
|
|
17
|
+
monohunter/detect/__init__.py
|
|
18
|
+
monohunter/detect/base.py
|
|
19
|
+
monohunter/detect/box.py
|
|
20
|
+
tests/test_detect_box.py
|
|
21
|
+
tests/test_detrend.py
|
|
22
|
+
tests/test_fetch.py
|
|
23
|
+
tests/test_pipeline.py
|
|
24
|
+
tests/test_record.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
monohunter
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "monohunter"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Find single long-period (mono-)transits in public TESS light curves that periodic pipelines under-find."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = [
|
|
13
|
+
"numpy",
|
|
14
|
+
"scipy",
|
|
15
|
+
"matplotlib",
|
|
16
|
+
"pydantic>=2",
|
|
17
|
+
"lightkurve>=2.5",
|
|
18
|
+
"wotan",
|
|
19
|
+
"astroquery",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = ["pytest"]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
monohunter = "monohunter.cli:main"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
include = ["monohunter*"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""T3 tests — the box detector must recover a real dip AND reject flat noise."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from monohunter.detect import BoxMatchedFilter, Candidate
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _time_axis(n=2000, cadence_min=2.0):
|
|
9
|
+
dt = cadence_min / (60.0 * 24.0) # days
|
|
10
|
+
return np.arange(n) * dt
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_flat_noise_yields_no_candidate():
|
|
14
|
+
rng = np.random.default_rng(0)
|
|
15
|
+
time = _time_axis()
|
|
16
|
+
flux = 1.0 + rng.normal(0.0, 5e-4, size=time.size)
|
|
17
|
+
assert BoxMatchedFilter().search(time, flux) == []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_injected_transit_is_recovered():
|
|
21
|
+
rng = np.random.default_rng(1)
|
|
22
|
+
time = _time_axis()
|
|
23
|
+
flux = 1.0 + rng.normal(0.0, 5e-4, size=time.size)
|
|
24
|
+
|
|
25
|
+
# Inject a ~24h box dip, depth 5 ppt, centered in the light curve.
|
|
26
|
+
dt = time[1] - time[0]
|
|
27
|
+
half = int((0.5 / 24.0) / dt) * 24 # ~12h half-width -> ~24h box
|
|
28
|
+
center = time.size // 2
|
|
29
|
+
flux[center - half : center + half] -= 5e-3
|
|
30
|
+
|
|
31
|
+
found = BoxMatchedFilter().search(time, flux)
|
|
32
|
+
assert len(found) == 1
|
|
33
|
+
cand = found[0]
|
|
34
|
+
assert isinstance(cand, Candidate)
|
|
35
|
+
assert cand.snr >= 7.0
|
|
36
|
+
assert cand.depth_ppt > 2.0 # recovered a real dip, not noise
|
|
37
|
+
# event time lands within the injected window
|
|
38
|
+
assert abs(cand.event_time_btjd - time[center]) < (half + 5) * dt
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_edge_ramp_is_not_a_candidate():
|
|
42
|
+
# Start-of-sector ramp (no real transit) must NOT fire — the S26 false-positive bug.
|
|
43
|
+
time = _time_axis()
|
|
44
|
+
flux = np.ones(time.size)
|
|
45
|
+
ramp = 300
|
|
46
|
+
flux[:ramp] = np.linspace(0.995, 1.0, ramp) # rising ramp at the left edge
|
|
47
|
+
rng = np.random.default_rng(2)
|
|
48
|
+
flux += rng.normal(0.0, 5e-4, size=time.size)
|
|
49
|
+
assert BoxMatchedFilter().search(time, flux) == []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_short_light_curve_is_safe():
|
|
53
|
+
assert BoxMatchedFilter().search(np.arange(4.0), np.ones(4)) == []
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_zero_scatter_is_safe():
|
|
57
|
+
# A perfectly flat curve has sigma 0 — must not divide by zero.
|
|
58
|
+
time = _time_axis(n=500)
|
|
59
|
+
assert BoxMatchedFilter().search(time, np.ones(time.size)) == []
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""T5 tests — the top footgun: a too-short detrend window EATS the transit.
|
|
2
|
+
|
|
3
|
+
This regression guards the #1 mistake from the reproduce-by-hand notebook.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from monohunter.detrend import flatten
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _curve_with_dip():
|
|
12
|
+
# 27-day sector, slow sinusoidal stellar trend, one ~24h box dip of 1%.
|
|
13
|
+
time = np.linspace(0.0, 27.0, 27 * 720) # 2-min cadence
|
|
14
|
+
trend = 1.0 + 0.01 * np.sin(2 * np.pi * time / 13.0)
|
|
15
|
+
flux = trend.copy()
|
|
16
|
+
center = time.size // 2
|
|
17
|
+
half = 360 # ~12h -> 24h dip
|
|
18
|
+
flux[center - half : center + half] -= 0.01
|
|
19
|
+
return time, flux
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_long_window_preserves_dip_short_window_eats_it():
|
|
23
|
+
time, flux = _curve_with_dip()
|
|
24
|
+
|
|
25
|
+
flat_good, _ = flatten(time, flux, window_length=3.0) # >> 1-day transit
|
|
26
|
+
flat_bad, _ = flatten(time, flux, window_length=0.5) # < 1-day transit
|
|
27
|
+
|
|
28
|
+
depth_good = 1.0 - np.nanmin(flat_good)
|
|
29
|
+
depth_bad = 1.0 - np.nanmin(flat_bad)
|
|
30
|
+
|
|
31
|
+
# Good window keeps most of the 1% dip.
|
|
32
|
+
assert depth_good > 5e-3
|
|
33
|
+
# Short window flattens the dip away — strictly shallower.
|
|
34
|
+
assert depth_bad < depth_good
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""T4 tests — sector dedup (prefer 2-min) and streaming, no network."""
|
|
2
|
+
|
|
3
|
+
from monohunter.fetch import iter_lightcurves, resolve_sectors
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_prefers_2min_and_dedups_by_sector():
|
|
7
|
+
rows = [
|
|
8
|
+
{"sector": 25, "cadence_s": 20},
|
|
9
|
+
{"sector": 25, "cadence_s": 120}, # 2-min duplicate of S25 — should win
|
|
10
|
+
{"sector": 26, "cadence_s": 20}, # only 20-sec available for S26
|
|
11
|
+
]
|
|
12
|
+
resolved = resolve_sectors(rows)
|
|
13
|
+
assert [(r["sector"], r["cadence_s"]) for r in resolved] == [(25, 120), (26, 20)]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_sorted_by_sector():
|
|
17
|
+
rows = [{"sector": 40, "cadence_s": 120}, {"sector": 14, "cadence_s": 120}]
|
|
18
|
+
assert [r["sector"] for r in resolve_sectors(rows)] == [14, 40]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_iter_streams_deduped_rows_one_at_a_time():
|
|
22
|
+
rows = [
|
|
23
|
+
{"sector": 25, "cadence_s": 20},
|
|
24
|
+
{"sector": 25, "cadence_s": 120},
|
|
25
|
+
{"sector": 26, "cadence_s": 120},
|
|
26
|
+
]
|
|
27
|
+
downloaded = []
|
|
28
|
+
|
|
29
|
+
def fake_download(row):
|
|
30
|
+
downloaded.append((row["sector"], row["cadence_s"]))
|
|
31
|
+
return f"lc-{row['sector']}"
|
|
32
|
+
|
|
33
|
+
out = list(iter_lightcurves(rows, fake_download))
|
|
34
|
+
|
|
35
|
+
# Only the deduped set was downloaded (no 20-sec S25 duplicate).
|
|
36
|
+
assert downloaded == [(25, 120), (26, 120)]
|
|
37
|
+
assert out[0][1] == "lc-25"
|
|
38
|
+
assert out[1][1] == "lc-26"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""T6/T7 tests — pipeline assembles a valid FindRecord end to end, no network.
|
|
2
|
+
|
|
3
|
+
We fake the two network touchpoints (search_tess, known_toi) and feed a light
|
|
4
|
+
curve with an injected dip through the real detrend + detect + record path.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from monohunter import pipeline
|
|
10
|
+
from monohunter.record import FindRecord
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _FakeQuantity:
|
|
14
|
+
def __init__(self, arr):
|
|
15
|
+
self.value = np.asarray(arr, dtype=float)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _FakeLC:
|
|
19
|
+
def __init__(self, time, flux):
|
|
20
|
+
self.time = _FakeQuantity(time)
|
|
21
|
+
self.flux = _FakeQuantity(flux)
|
|
22
|
+
|
|
23
|
+
def remove_nans(self):
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
def normalize(self):
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _FakeSel:
|
|
31
|
+
def __init__(self, lc):
|
|
32
|
+
self._lc = lc
|
|
33
|
+
|
|
34
|
+
def download(self):
|
|
35
|
+
return self._lc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _FakeSR:
|
|
39
|
+
def __init__(self, lc):
|
|
40
|
+
self._lc = lc
|
|
41
|
+
|
|
42
|
+
def __getitem__(self, idx):
|
|
43
|
+
return _FakeSel(self._lc)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _lc_with_dip():
|
|
47
|
+
dt = 2.0 / (60 * 24) # 2-min cadence in days
|
|
48
|
+
time = np.arange(2000) * dt
|
|
49
|
+
rng = np.random.default_rng(3)
|
|
50
|
+
flux = 1.0 + rng.normal(0, 5e-4, size=time.size)
|
|
51
|
+
c = time.size // 2
|
|
52
|
+
flux[c - 360 : c + 360] -= 6e-3 # ~24h, 6 ppt dip
|
|
53
|
+
return time, flux
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_run_target_builds_valid_record(monkeypatch, tmp_path):
|
|
57
|
+
time, flux = _lc_with_dip()
|
|
58
|
+
fake_sr = _FakeSR(_FakeLC(time, flux))
|
|
59
|
+
rows = [{"sector": 25, "cadence_s": 120, "_index": 0}]
|
|
60
|
+
|
|
61
|
+
monkeypatch.setattr(pipeline, "search_tess", lambda tic: (fake_sr, rows))
|
|
62
|
+
monkeypatch.setattr(pipeline, "known_toi", lambda tic: (True, "TOI-2180"))
|
|
63
|
+
|
|
64
|
+
records = pipeline.run_target(
|
|
65
|
+
298663873, outdir=str(tmp_path), make_plots=True
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
assert len(records) == 1
|
|
69
|
+
rec = records[0]
|
|
70
|
+
assert isinstance(rec, FindRecord)
|
|
71
|
+
assert rec.tic == 298663873
|
|
72
|
+
assert rec.sector == 25
|
|
73
|
+
assert rec.known_toi_match is True
|
|
74
|
+
assert rec.known_toi_id == "TOI-2180"
|
|
75
|
+
assert rec.snr >= 7.0
|
|
76
|
+
assert rec.plot_path is not None
|
|
77
|
+
# plot actually written
|
|
78
|
+
import os
|
|
79
|
+
|
|
80
|
+
assert os.path.exists(rec.plot_path)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_sectors_filter_excludes_others(monkeypatch, tmp_path):
|
|
84
|
+
time, flux = _lc_with_dip()
|
|
85
|
+
fake_sr = _FakeSR(_FakeLC(time, flux))
|
|
86
|
+
rows = [
|
|
87
|
+
{"sector": 25, "cadence_s": 120, "_index": 0},
|
|
88
|
+
{"sector": 40, "cadence_s": 120, "_index": 0},
|
|
89
|
+
]
|
|
90
|
+
monkeypatch.setattr(pipeline, "search_tess", lambda tic: (fake_sr, rows))
|
|
91
|
+
monkeypatch.setattr(pipeline, "known_toi", lambda tic: (False, None))
|
|
92
|
+
|
|
93
|
+
records = pipeline.run_target(
|
|
94
|
+
1, outdir=str(tmp_path), make_plots=False, sectors=[25]
|
|
95
|
+
)
|
|
96
|
+
assert {r.sector for r in records} == {25}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_cli_run_wires_through(monkeypatch, tmp_path, capsys):
|
|
100
|
+
from monohunter import cli
|
|
101
|
+
|
|
102
|
+
time, flux = _lc_with_dip()
|
|
103
|
+
fake_sr = _FakeSR(_FakeLC(time, flux))
|
|
104
|
+
rows = [{"sector": 25, "cadence_s": 120, "_index": 0}]
|
|
105
|
+
monkeypatch.setattr(pipeline, "search_tess", lambda tic: (fake_sr, rows))
|
|
106
|
+
monkeypatch.setattr(pipeline, "known_toi", lambda tic: (False, None))
|
|
107
|
+
|
|
108
|
+
rc = cli.main(
|
|
109
|
+
["run", "--tic", "298663873", "--outdir", str(tmp_path), "--no-plot"]
|
|
110
|
+
)
|
|
111
|
+
assert rc == 0
|
|
112
|
+
out = capsys.readouterr().out
|
|
113
|
+
assert "S25" in out
|
|
114
|
+
assert (tmp_path / "tic298663873_s25.json").exists()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""T1 tests — FindRecord is the Swarm contract, so validation must be strict."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
|
|
8
|
+
from monohunter.record import SCHEMA_VERSION, FindRecord
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _valid_kwargs():
|
|
12
|
+
return dict(
|
|
13
|
+
tic=298663873,
|
|
14
|
+
sector=25,
|
|
15
|
+
cadence_s=120,
|
|
16
|
+
event_time_btjd=1955.3,
|
|
17
|
+
depth_ppt=4.2,
|
|
18
|
+
duration_hr=24.0,
|
|
19
|
+
snr=18.5,
|
|
20
|
+
detrend_method="biweight",
|
|
21
|
+
detrend_window_d=3.0,
|
|
22
|
+
tool_version="0.1.0",
|
|
23
|
+
known_toi_match=True,
|
|
24
|
+
known_toi_id="TOI-2180",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_valid_record_builds_and_versions():
|
|
29
|
+
rec = FindRecord(**_valid_kwargs())
|
|
30
|
+
assert rec.schema_version == SCHEMA_VERSION
|
|
31
|
+
assert rec.tic == 298663873
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_json_round_trip_preserves_fields():
|
|
35
|
+
rec = FindRecord(**_valid_kwargs())
|
|
36
|
+
back = FindRecord(**json.loads(rec.to_json()))
|
|
37
|
+
assert back == rec
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_unknown_field_is_rejected():
|
|
41
|
+
# A drifted/typo'd field must be a hard error, not silently accepted.
|
|
42
|
+
with pytest.raises(ValidationError):
|
|
43
|
+
FindRecord(**_valid_kwargs(), planet_radius=6.7)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_missing_required_field_is_rejected():
|
|
47
|
+
kwargs = _valid_kwargs()
|
|
48
|
+
del kwargs["tic"]
|
|
49
|
+
with pytest.raises(ValidationError):
|
|
50
|
+
FindRecord(**kwargs)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_negative_depth_is_rejected():
|
|
54
|
+
kwargs = _valid_kwargs()
|
|
55
|
+
kwargs["depth_ppt"] = -1.0
|
|
56
|
+
with pytest.raises(ValidationError):
|
|
57
|
+
FindRecord(**kwargs)
|