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.

@@ -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,3 @@
1
+ """MonoHunter — find single long-period (mono-)transits in public TESS light curves."""
2
+
3
+ __version__ = "0.1.0"
@@ -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,6 @@
1
+ """Detection engine: a Detector interface with pluggable implementations."""
2
+
3
+ from .base import Candidate, Detector
4
+ from .box import BoxMatchedFilter
5
+
6
+ __all__ = ["Candidate", "Detector", "BoxMatchedFilter"]
@@ -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,2 @@
1
+ [console_scripts]
2
+ monohunter = monohunter.cli:main
@@ -0,0 +1,10 @@
1
+ numpy
2
+ scipy
3
+ matplotlib
4
+ pydantic>=2
5
+ lightkurve>=2.5
6
+ wotan
7
+ astroquery
8
+
9
+ [dev]
10
+ pytest
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)