diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index 8a06ffc..bef3595 100755
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -16,17 +16,17 @@ jobs:
python-version: ["3.10", "3.12"]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Cache pip packages
- uses: actions/cache@v4
+ uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }}
@@ -34,7 +34,7 @@ jobs:
${{ runner.os }}-pip-
- name: Cache Lightkurve data
- uses: actions/cache@v4
+ uses: actions/cache@v6
with:
path: ~/.lightkurve/cache/mastDownload
key: ${{ runner.os }}-lightkurve-${{ hashFiles('pyproject.toml') }}
@@ -51,7 +51,7 @@ jobs:
pip install -e ".[dev,gui]"
- name: Run tests
- run: pytest -v -m "not notebook" -n auto
+ run: pytest -v -m "not notebook and not network" -n auto
- name: Verify package builds cleanly
run: |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2c04439..076984b 100755
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,12 +9,12 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: "3.12"
diff --git a/README.md b/README.md
index f788e45..7bd22ea 100755
--- a/README.md
+++ b/README.md
@@ -26,29 +26,52 @@ Although `quicklook` is optimized to find transiting exoplanets, it can also det
## Installation
-Requires Python 3.10+. Install with `uv` (recommended) or `pip`:
+Requires Python 3.10+. Choose either `uv` (recommended) or `pip` below.
+
+
+uv (recommended)
```bash
-# uv (recommended)
+# Command-line application
uv tool install quicklook-package
-# or pip
-pip install -U quicklook-package
+# Or install this repository, including development dependencies
+uv sync --extra dev
+
+# Optional extras for a repository installation
+uv sync --extra gui # Web GUI
+uv sync --extra gpu # GPU transit search (CUDA 12)
+uv sync --extra notebooks # Jupyter notebooks
```
-### Optional extras
+Run commands from a repository installation with `uv run`, for example
+`uv run quicklook --help`.
-```bash
-# Web GUI
-pip install -U "quicklook-package[gui]"
+
-# Jupyter notebooks
-pip install -U "quicklook-package[notebooks]"
+
+pip
-# Development (testing, linting, formatting)
-pip install -U "quicklook-package[dev]"
+```bash
+# Command-line application
+python -m pip install -U quicklook-package
+
+# Optional extras
+python -m pip install -U "quicklook-package[gui]" # Web GUI
+python -m pip install -U "quicklook-package[gpu]" # GPU transit search (CUDA 12)
+python -m pip install -U "quicklook-package[notebooks]" # Jupyter notebooks
+python -m pip install -U "quicklook-package[dev]" # Development tools
```
+Once installed, run commands directly, for example `quicklook --help` or
+`ql --help`.
+
+
+
+With the `gpu` extra installed, QuickLook uses GTLS when a CUDA device is
+visible and automatically falls back to the standard CPU TLS implementation
+when GTLS or a GPU is unavailable, or when GPU execution fails to initialize.
+
## Try it on Google Colab
@@ -57,15 +80,20 @@ pip install -U "quicklook-package[dev]"
### Command line
-The `ql` CLI provides subcommands for analysis and post-processing:
+A single `quicklook` command groups every subcommand (the shorter `ql` alias
+is equivalent):
```bash
-ql --help # show commands: run, read-tls, rank-tls
-ql run --help # full analysis options
-ql read-tls --help # extract TLS results to CSV
-ql rank-tls --help # filter and rank candidates
+uv run quicklook --help # show commands: run, read-tls, rank-tls, gui
+uv run quicklook run --help # full analysis options
+uv run quicklook read-tls --help # extract TLS results to CSV
+uv run quicklook rank-tls --help # filter and rank candidates
+uv run quicklook gui --help # launch the web GUI
```
+Drop the `uv run` prefix once the package is installed on your `PATH`
+(e.g. `quicklook run ...` or `ql run ...`).
+
```bash
# Basic run on the latest TESS sector
ql run --name WASP-21 --save --verbose
@@ -128,22 +156,25 @@ locally via effective-PSF (ePSF) photometry.
### Web GUI
```bash
-ql-gui
+uv run quicklook gui # http://127.0.0.1:5000
+uv run quicklook gui --host 0.0.0.0 --port 8080
```
Open http://127.0.0.1:5000 in your browser. Enter a target, adjust parameters, and click **Run QuickLook**. Progress is streamed live via WebSocket. Supports single targets, batch submission, and each-sector mode.

-The Flask debugger is off by default. Set `QUICKLOOK_DEBUG=1` to enable it and
-the auto-reloader while developing:
+The Flask debugger is off by default. Pass `--debug` (or set `QUICKLOOK_DEBUG=1`)
+to enable it and the auto-reloader while developing:
```bash
-QUICKLOOK_DEBUG=1 ql-gui
+uv run quicklook gui --debug
+QUICKLOOK_DEBUG=1 uv run quicklook gui
```
-Leave it unset on any host other users can reach — the Werkzeug debugger
-exposes an interactive console to whoever can open the port.
+Leave it off on any host other users can reach — the Werkzeug debugger
+exposes an interactive console to whoever can open the port. The standalone
+`ql-gui` command remains available as an alias for `quicklook gui`.
## Output figure
@@ -169,12 +200,14 @@ The 9-panel figure shows:
## CLI tools
+All subcommands are available under either `quicklook` or the shorter `ql` alias.
+
| Command | Description |
|---------|-------------|
-| `ql run` | Run the full QuickLook pipeline on a target |
-| `ql read-tls` | Extract TLS results from a directory of `.h5` files into a CSV |
-| `ql rank-tls` | Filter and rank candidates by SDE from the CSV output |
-| `ql-gui` | Launch the web GUI (requires `[gui]` extra) |
+| `quicklook run` | Run the full QuickLook pipeline on a target |
+| `quicklook read-tls` | Extract TLS results from a directory of `.h5` files into a CSV |
+| `quicklook rank-tls` | Filter and rank candidates by SDE from the CSV output |
+| `quicklook gui` | Launch the web GUI (requires `[gui]` extra); also available as `ql-gui` |
## Batch processing
diff --git a/pyproject.toml b/pyproject.toml
index c3aca81..f9da425 100755
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,12 +63,17 @@ dev = [
gui = ["flask", "flask-sock", "pytest-flask"]
+gpu = ["gputls", "cupy-cuda12x[ctk]"]
+
notebooks = [
"ipykernel",
"jupyter",
"nbconvert"
]
+[tool.uv.sources]
+gputls = { git = "https://github.com/jpdeleon/GTLS.git" }
+
[project.urls]
Homepage = "https://github.com/jpdeleon/quicklook"
@@ -97,7 +102,8 @@ ignore = [
]
[project.scripts]
-ql = "quicklook.cli.ql:main"
+quicklook = "quicklook.cli.app:app"
+ql = "quicklook.cli.app:app"
read_tls = "quicklook.cli.read_tls:main"
rank_tls = "quicklook.cli.rank_tls:main"
ql-gui = "quicklook.app.app:main"
diff --git a/quicklook/app/app.py b/quicklook/app/app.py
index 156139d..fd37ac5 100644
--- a/quicklook/app/app.py
+++ b/quicklook/app/app.py
@@ -61,6 +61,9 @@ def __init__(self, fallback):
def set_stream(self, stream):
self._local.stream = stream
+ def set_fallback(self, stream):
+ self._fallback = stream
+
def clear_stream(self):
self._local.stream = None
@@ -84,8 +87,6 @@ def encoding(self):
_tls_stdout = _ThreadLocalStream(_real_stdout)
_tls_stderr = _ThreadLocalStream(_real_stderr)
-sys.stdout = _tls_stdout
-sys.stderr = _tls_stderr
# ---------------------------------------------------------------------------
@@ -281,7 +282,12 @@ def parse_sigma(val, default_lo=10, default_hi=5):
# Open the log file and set up streams — file stays open until the
# outermost finally block so no concurrent write hits a closed FD.
- log_fh = open(log_file, "w", buffering=1, encoding="utf-8")
+ # Keep stdout/stderr in append mode too. Loguru uses a separate
+ # append-mode descriptor below; a normal ``w`` descriptor would keep
+ # its own offset and overwrite Loguru messages written in between.
+ log_fh = open(log_file, "a", buffering=1, encoding="utf-8")
+ log_fh.seek(0)
+ log_fh.truncate()
_tls_stdout.set_stream(log_fh)
_tls_stderr.set_stream(log_fh)
@@ -1392,11 +1398,46 @@ def compare():
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
+def run_gui(host="127.0.0.1", port=5000, debug=None):
+ """Launch the Flask development server for the QuickLook GUI.
+
+ Parameters
+ ----------
+ host : str
+ Interface to bind to (default ``127.0.0.1``, localhost only).
+ port : int
+ Port to listen on (default ``5000``).
+ debug : bool | None
+ Enable the Werkzeug debugger and auto-reloader. ``None`` (the default)
+ consults the ``QUICKLOOK_DEBUG`` environment variable. The debugger
+ exposes an interactive console (arbitrary code execution) to anyone who
+ can reach the port, so it is opt-in.
+ """
+ if debug is None:
+ debug = os.environ.get("QUICKLOOK_DEBUG", "").lower() in ("1", "true", "yes")
+
+ # Route output only while the server is running. Installing these wrappers
+ # at module import time captured temporary streams owned by pytest, Typer,
+ # and other embedders; once those streams closed, later writes failed with
+ # ``ValueError: I/O operation on closed file``.
+ previous_stdout = sys.stdout
+ previous_stderr = sys.stderr
+ _tls_stdout.set_fallback(previous_stdout)
+ _tls_stderr.set_fallback(previous_stderr)
+ sys.stdout = _tls_stdout
+ sys.stderr = _tls_stderr
+ try:
+ app.run(host=host, port=port, debug=debug, threaded=True)
+ finally:
+ if sys.stdout is _tls_stdout:
+ sys.stdout = previous_stdout
+ if sys.stderr is _tls_stderr:
+ sys.stderr = previous_stderr
+
+
def main():
- # The Werkzeug debugger exposes an interactive console (arbitrary code
- # execution) to anyone who can reach the port, so it is opt-in.
- debug = os.environ.get("QUICKLOOK_DEBUG", "").lower() in ("1", "true", "yes")
- app.run(debug=debug, threaded=True)
+ """Entry point for the ``ql-gui`` console script."""
+ run_gui()
if __name__ == "__main__":
diff --git a/quicklook/cli/app.py b/quicklook/cli/app.py
index 7a29aa5..481dde0 100644
--- a/quicklook/cli/app.py
+++ b/quicklook/cli/app.py
@@ -600,5 +600,31 @@ def rank_tls(
typer.echo(f"Copied: {src_path} -> {dst_path}")
+@app.command()
+def gui(
+ host: str = typer.Option("127.0.0.1", "--host", help="Interface to bind (default: localhost)"),
+ port: int = typer.Option(5000, "--port", help="Port to listen on"),
+ debug: bool = typer.Option(
+ False, "--debug", help="Enable the Werkzeug debugger and auto-reloader (dev only)"
+ ),
+):
+ """Launch the QuickLook web GUI (Flask). Needs the optional gui extra.
+
+ Open http://: in a browser to run analyses interactively.
+ The Werkzeug debugger exposes an interactive console, so ``--debug`` is
+ opt-in; leave it off on any host other users can reach.
+
+ Examples:
+
+ quicklook gui
+ quicklook gui --host 0.0.0.0 --port 8080
+ """
+ from quicklook.app.app import run_gui
+
+ # An explicit --debug forces the debugger on; without it, defer to the
+ # QUICKLOOK_DEBUG environment variable handled inside run_gui.
+ run_gui(host=host, port=port, debug=True if debug else None)
+
+
if __name__ == "__main__":
app()
diff --git a/quicklook/cli/ql.py b/quicklook/cli/ql.py
index 704598b..4b1a20d 100755
--- a/quicklook/cli/ql.py
+++ b/quicklook/cli/ql.py
@@ -1,7 +1,9 @@
#!/usr/bin/env python
-"""Target-name sanitization and CLI redirect to the unified Typer app."""
+"""Target-name sanitization shared by the CLI and web GUI.
-import sys as _sys
+The user-facing commands live in :mod:`quicklook.cli.app` (the unified Typer
+app exposed as the ``quicklook`` and ``ql`` console scripts).
+"""
from quicklook.exceptions import InvalidInputError
@@ -34,15 +36,3 @@ def sanitize_target_name(name: str) -> str:
if any(char in name for char in ("/", "\\", "\x00")) or ".." in name or name.startswith("."):
raise InvalidInputError(f"Invalid target name: {name!r}")
return name
-
-
-def main():
- """Redirect to the unified Typer CLI (``ql run``)."""
- _sys.argv = [_sys.argv[0], "run"] + _sys.argv[1:]
- from quicklook.cli.app import app
-
- app()
-
-
-if __name__ == "__main__":
- main()
diff --git a/quicklook/tql.py b/quicklook/tql.py
index 1ea7e9e..2611e17 100755
--- a/quicklook/tql.py
+++ b/quicklook/tql.py
@@ -15,7 +15,7 @@
from time import time as timer
from loguru import logger
from importlib.resources import files
-from quicklook.exceptions import NoDataError, InvalidInputError, PipelineError
+from quicklook.exceptions import NoDataError, InvalidInputError
import matplotlib.pyplot as pl
import numpy as np
import pandas as pd
@@ -69,6 +69,74 @@
__all__ = ["TessQuickLook"]
+class _TLSResult(dict):
+ """TLS-compatible mapping used to normalize GTLS results."""
+
+ def __getattr__(self, name):
+ try:
+ return self[name]
+ except KeyError as exc:
+ raise AttributeError(name) from exc
+
+
+def _gpu_device_count():
+ """Return the number of CUDA devices visible to CuPy."""
+ import cupy
+
+ return cupy.cuda.runtime.getDeviceCount()
+
+
+def _get_gpu_tls():
+ """Return GTLS when it is installed and a CUDA GPU is visible."""
+ try:
+ device_count = _gpu_device_count()
+ except Exception as exc:
+ logger.debug(f"GPU TLS unavailable ({exc}); using CPU TLS")
+ return None
+
+ if device_count < 1:
+ logger.info("No CUDA GPU detected; using CPU TLS")
+ return None
+
+ try:
+ from gputls import gtls
+ except Exception as exc:
+ logger.warning(f"CUDA GPU detected but GTLS could not be loaded ({exc}); using CPU TLS")
+ return None
+
+ return gtls
+
+
+def _adapt_gtls_result(result, model):
+ """Expose a GTLS result through the mapping/attribute API QuickLook uses."""
+ values = vars(result).copy()
+ fractional_depth = float(values["depth"])
+ values["depth"] = 1.0 - fractional_depth
+ values["rp_rs"] = np.sqrt(max(0.0, fractional_depth))
+ values["odd_even_mismatch"] = np.nan
+
+ periods = np.asarray(values["periods"])
+ power = np.asarray(values["power"])
+ try:
+ peak = int(np.nanargmax(power))
+ half_max = power[peak] / 2
+ lower = np.flatnonzero(power[:peak] <= half_max)
+ upper = np.flatnonzero(power[peak + 1 :] <= half_max)
+ if len(lower) and len(upper):
+ lo = lower[-1]
+ hi = peak + 1 + upper[0]
+ values["period_uncertainty"] = 0.5 * (periods[hi] - periods[lo])
+ else:
+ values["period_uncertainty"] = np.inf
+ except (ValueError, IndexError):
+ values["period_uncertainty"] = np.inf
+
+ model_flux, model_phase, _ = model.showFit()
+ values["model_folded_phase"] = np.asarray(model_phase)
+ values["model_folded_model"] = np.asarray(model_flux)
+ return _TLSResult(values)
+
+
class TessQuickLook:
def __init__(
self,
@@ -155,10 +223,15 @@ def __init__(
self.window_length = window_length
self.tmask = self.get_transit_mask()
- err_msg = "No masked transits"
+ # A known ephemeris that predicts no transit within the downloaded
+ # data is expected for long-period planets whose transit simply does
+ # not fall in this ~27-day sector (e.g. TOI-2074, P=177.6 d). This is
+ # not a failure: flattening, the TLS search, and the plots still run.
+ # Warn (pointing at the nearest predicted transit) and continue rather
+ # than aborting the whole quicklook.
if self.toi_epoch is not None and self.tmask.sum() == 0:
- raise PipelineError(err_msg)
- if self.mask_ephem:
+ self._warn_no_transits_in_sector()
+ if self.mask_ephem and self.tmask.sum() > 0:
if self.verbose:
logger.info(
f"Masking transits in raw lightcurve using {self.ephem_source} ephem..."
@@ -968,12 +1041,25 @@ def run_tls(self):
power_kwargs.update(self._stellar_prior_kwargs())
else:
logger.info("use_priors=False: TLS will use Sun-like defaults (R_star=1, M_star=1)")
- self.tls_results = tls(
+ gpu_tls = _get_gpu_tls()
+ engine_args = (
self.flat_lc.time.value,
self.flat_lc.flux.value,
flux_err,
- verbose=self.verbose,
- ).power(**power_kwargs)
+ )
+ if gpu_tls:
+ logger.info("Running GTLS on GPU")
+ try:
+ model = gpu_tls(*engine_args, verbose=self.verbose)
+ result = model.power(**power_kwargs)
+ self.tls_results = _adapt_gtls_result(result, model)
+ return
+ except Exception as exc:
+ logger.warning(f"GTLS failed ({exc}); retrying with CPU TLS")
+
+ logger.info("Running TLS on CPU")
+ model = tls(*engine_args, verbose=self.verbose)
+ self.tls_results = model.power(**power_kwargs)
def _stellar_prior_kwargs(self):
"""Pull R_star, M_star (and ±1σ bounds) from ExoFOP for the TLS prior.
@@ -1170,6 +1256,37 @@ def get_transit_mask(self):
tmask = np.zeros_like(self.raw_lc.time.value, dtype=bool)
return tmask
+ def _warn_no_transits_in_sector(self):
+ """Warn that the known ephemeris predicts no transit in the data.
+
+ For long-period planets the transit often falls outside the ~27-day
+ TESS sector that was downloaded, so there is simply nothing to mask.
+ This is expected, not an error: the quicklook (flattening, TLS search,
+ plots) still runs. Report the sector's time coverage and the nearest
+ predicted transit so the user can pick a sector that actually contains
+ a transit.
+ """
+ time = self.raw_lc.time.value
+ t_start, t_end = float(np.nanmin(time)), float(np.nanmax(time))
+ epoch, period = float(self.toi_epoch[0]), float(self.toi_period[0])
+ msg = (
+ f"No transit predicted by the {self.ephem_source} ephem falls "
+ f"within sector {self.sector} (BTJD {t_start:.2f}-{t_end:.2f}); "
+ "nothing to mask."
+ )
+ if period > 0:
+ # Nearest transit epoch to the middle of the observed baseline.
+ t_mid = 0.5 * (t_start + t_end)
+ nearest = epoch + round((t_mid - epoch) / period) * period
+ gap = max(t_start - nearest, nearest - t_end, 0.0)
+ msg += (
+ f" Nearest predicted transit at BTJD {nearest:.2f} "
+ f"({gap:.1f} d outside coverage, P={period:.4f} d)."
+ )
+ if self.all_sectors is not None and len(self.all_sectors) > 1:
+ msg += f" Try another sector: {self.all_sectors}."
+ logger.warning(msg)
+
def _summary_sections(self):
"""Build the summary as structured (label, value) rows per section.
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 245f211..562ad76 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,5 +1,6 @@
"""Tests for the unified Typer CLI (quicklook.cli.app)."""
+from click import unstyle
from typer.testing import CliRunner
from quicklook.cli.app import app
@@ -91,20 +92,65 @@ def test_run_aborts_on_headless_without_save():
os.environ["DISPLAY"] = had_display
-def test_run_with_save_skips_headless_check():
- """With --save the headless check is bypassed. The pipeline may still fail
- for other reasons, but it shouldn't exit with headless-abort code 1."""
- import os
- from loguru import logger
+def test_run_with_save_skips_headless_check(monkeypatch):
+ """With --save, a headless run reaches the analysis pipeline."""
+ import quicklook.tql as tql_mod
- logger.disable("quicklook")
- had_display = os.environ.pop("DISPLAY", None)
- try:
- result = runner.invoke(
- app, ["run", "--name", "TOI-6109", "--save", "--overwrite", "--verbose"]
- )
- assert result.exit_code != 1, f"unexpected headless abort: {result.output}"
- finally:
- if had_display is not None:
- os.environ["DISPLAY"] = had_display
- logger.enable("quicklook")
+ calls = {}
+
+ class FakeQuickLook:
+ def __init__(self, **kwargs):
+ calls.update(kwargs)
+
+ def plot_tql(self):
+ calls["plot_tql"] = True
+
+ monkeypatch.setattr(tql_mod, "TessQuickLook", FakeQuickLook)
+ monkeypatch.delenv("DISPLAY", raising=False)
+
+ result = runner.invoke(app, ["run", "--name", "TOI-6109", "--save"])
+
+ assert result.exit_code == 0, result.output
+ assert calls["savefig"] is True
+ assert calls["savetls"] is True
+ assert calls["plot_tql"] is True
+
+
+def test_gui_help_succeeds():
+ result = runner.invoke(app, ["gui", "--help"])
+ output = unstyle(result.output)
+ assert result.exit_code == 0
+ assert "web" in output.lower() or "gui" in output.lower()
+ assert "--host" in output
+ assert "--port" in output
+
+
+def test_gui_invokes_run_gui_with_options(monkeypatch):
+ """`quicklook gui --host ... --port ...` forwards to run_gui without
+ actually starting the blocking Flask server."""
+ import quicklook.app.app as gui_module
+
+ calls = {}
+
+ def fake_run_gui(host="127.0.0.1", port=5000, debug=None):
+ calls.update(host=host, port=port, debug=debug)
+
+ monkeypatch.setattr(gui_module, "run_gui", fake_run_gui)
+ result = runner.invoke(app, ["gui", "--host", "0.0.0.0", "--port", "8080"])
+ assert result.exit_code == 0, result.output
+ assert calls["host"] == "0.0.0.0"
+ assert calls["port"] == 8080
+
+
+def test_gui_debug_flag_forwarded(monkeypatch):
+ import quicklook.app.app as gui_module
+
+ calls = {}
+
+ def fake_run_gui(host="127.0.0.1", port=5000, debug=None):
+ calls.update(host=host, port=port, debug=debug)
+
+ monkeypatch.setattr(gui_module, "run_gui", fake_run_gui)
+ result = runner.invoke(app, ["gui", "--debug"])
+ assert result.exit_code == 0, result.output
+ assert calls["debug"] is True
diff --git a/tests/test_gpu_tls.py b/tests/test_gpu_tls.py
new file mode 100644
index 0000000..b70d5d9
--- /dev/null
+++ b/tests/test_gpu_tls.py
@@ -0,0 +1,107 @@
+import sys
+import types
+from pathlib import Path
+
+import numpy as np
+
+from quicklook import tql
+
+
+def test_gpu_extra_installs_cuda_toolkit_headers():
+ pyproject = (Path(__file__).parents[1] / "pyproject.toml").read_text()
+
+ assert 'gpu = ["gputls", "cupy-cuda12x[ctk]"]' in pyproject
+
+
+def test_gpu_tls_is_selected_when_cuda_device_is_visible(monkeypatch):
+ sentinel = object()
+ monkeypatch.setattr(tql, "_gpu_device_count", lambda: 1)
+ monkeypatch.setitem(sys.modules, "gputls", types.SimpleNamespace(gtls=sentinel))
+
+ assert tql._get_gpu_tls() is sentinel
+
+
+def test_cpu_tls_is_fallback_when_no_cuda_device_is_visible(monkeypatch):
+ monkeypatch.setattr(tql, "_gpu_device_count", lambda: 0)
+
+ assert tql._get_gpu_tls() is None
+
+
+def test_cpu_tls_is_fallback_when_gtls_is_not_installed(monkeypatch):
+ monkeypatch.setattr(tql, "_gpu_device_count", lambda: 1)
+ monkeypatch.setitem(sys.modules, "gputls", None)
+
+ assert tql._get_gpu_tls() is None
+
+
+def test_cpu_tls_is_fallback_when_gpu_detection_fails(monkeypatch):
+ def fail_detection():
+ raise RuntimeError("CUDA driver unavailable")
+
+ monkeypatch.setattr(tql, "_gpu_device_count", fail_detection)
+
+ assert tql._get_gpu_tls() is None
+
+
+def test_cpu_tls_is_fallback_when_gtls_execution_fails(monkeypatch):
+ cpu_result = object()
+
+ class BrokenGTLS:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def power(self, **kwargs):
+ raise RuntimeError("Failed to find CUDA headers")
+
+ class RecordingTLS:
+ calls = 0
+
+ def __init__(self, *args, **kwargs):
+ RecordingTLS.calls += 1
+
+ def power(self, **kwargs):
+ return cpu_result
+
+ monkeypatch.setattr(tql, "_get_gpu_tls", lambda: BrokenGTLS)
+ monkeypatch.setattr(tql, "tls", RecordingTLS)
+
+ values = np.linspace(0, 1, 10)
+ qlook = tql.TessQuickLook.__new__(tql.TessQuickLook)
+ qlook.flat_lc = types.SimpleNamespace(
+ time=types.SimpleNamespace(value=values),
+ flux=types.SimpleNamespace(value=np.ones(10)),
+ flux_err=types.SimpleNamespace(value=np.full(10, 0.01)),
+ )
+ qlook.Porb_min = 0.5
+ qlook.Porb_max = 10.0
+ qlook.tls_use_threads = None
+ qlook.use_star_priors = False
+ qlook.verbose = False
+
+ qlook.run_tls()
+
+ assert qlook.tls_results is cpu_result
+ assert RecordingTLS.calls == 1
+
+
+def test_gtls_result_is_adapted_to_tls_conventions():
+ result = types.SimpleNamespace(
+ depth=0.01,
+ periods=np.array([1.0, 2.0, 3.0]),
+ power=np.array([0.1, 1.0, 0.1]),
+ period=2.0,
+ duration=0.1,
+ T0=0.5,
+ SDE=8.0,
+ )
+
+ class Model:
+ def showFit(self):
+ return [1.0, 0.99], [0.0, 0.5], [1.0, 0.99]
+
+ adapted = tql._adapt_gtls_result(result, Model())
+
+ assert adapted.depth == 0.99
+ assert adapted["rp_rs"] == 0.1
+ assert adapted.period_uncertainty == 1.0
+ np.testing.assert_array_equal(adapted.model_folded_phase, [0.0, 0.5])
diff --git a/tests/test_phase_xlim.py b/tests/test_phase_xlim.py
index 25f6823..f3cd03c 100644
--- a/tests/test_phase_xlim.py
+++ b/tests/test_phase_xlim.py
@@ -25,13 +25,28 @@ def test_phase_xlim_rejects_invalid_delta(bad_value):
_phase_window(0, 0.02, bad_value)
-def test_cli_phase_xlim_flag_wired():
- import quicklook.cli.ql as ql_mod
+def test_cli_phase_xlim_flag_wired(monkeypatch):
+ """The unified Typer option reaches ``TessQuickLook``."""
+ from typer.testing import CliRunner
- src = open(ql_mod.__file__).read()
- assert '"--phase_xlim"' in src
- assert "phase_xlim=args.phase_xlim" in src
- assert "args.phase_xlim" in src
+ import quicklook.tql as tql_mod
+ from quicklook.cli.app import app
+
+ calls = {}
+
+ class FakeQuickLook:
+ def __init__(self, **kwargs):
+ calls.update(kwargs)
+
+ def plot_tql(self):
+ return None
+
+ monkeypatch.setattr(tql_mod, "TessQuickLook", FakeQuickLook)
+
+ result = CliRunner().invoke(app, ["run", "--name", "TOI-1234", "--phase-xlim", "0.1", "--save"])
+
+ assert result.exit_code == 0, result.output
+ assert calls["phase_xlim"] == pytest.approx(0.1)
def test_gui_phase_xlim_input_wired():
diff --git a/tests/test_tql.py b/tests/test_tql.py
index 15ee926..0c0a512 100755
--- a/tests/test_tql.py
+++ b/tests/test_tql.py
@@ -145,36 +145,118 @@ def run_ql():
def test_with_mock_light_curve(mock_light_curve, planet_inputs):
- """Test TessQuickLook with a mock light curve"""
+ """Test TessQuickLook with a mock light curve and no external services."""
inputs = planet_inputs.copy()
+ inputs["tls_use_threads"] = 1
+ exofop_data = {
+ "basic_info": {
+ "star_names": "WASP-21, TIC 234523599, Gaia DR3 2345395591154370176",
+ "tic_id": "234523599",
+ },
+ "coordinates": {"ra": 349.8985, "dec": -10.3270},
+ "planet_parameters": [],
+ }
- # We need to patch multiple methods to avoid network calls and initialization issues
- with patch.object(TessQuickLook, "get_lc", return_value=mock_light_curve):
- # Patch check_output_file_exists to avoid pipeline attribute error
- with patch.object(TessQuickLook, "check_output_file_exists", return_value=None):
- # Create the TessQuickLook instance
- ql = TessQuickLook(**inputs)
-
- # Manually set the required attributes
- ql.pipeline = inputs["pipeline"].lower()
- ql.sector = inputs["sector"]
-
- # Check that the light curve was set correctly
- assert ql.raw_lc is mock_light_curve
-
- # Check that the flattened light curve was created
- assert isinstance(ql.flat_lc, lk.LightCurve)
- assert isinstance(ql.trend_lc, lk.LightCurve)
-
- # Check that TLS was run
- assert hasattr(ql, "tls_results")
- assert hasattr(ql.tls_results, "period")
-
- # Test basic attributes
- assert ql.target_name == inputs["target_name"]
- assert ql.flux_type == inputs["flux_type"].lower()
- assert ql.pipeline == inputs["pipeline"].lower()
- assert ql.sector == inputs["sector"]
+ with (
+ patch("quicklook.tql.get_exofop_json", return_value=exofop_data),
+ patch.object(TessQuickLook, "get_simbad_obj_type", return_value=None),
+ patch.object(TessQuickLook, "get_lc", return_value=mock_light_curve),
+ patch.object(TessQuickLook, "check_output_file_exists", return_value=None),
+ ):
+ ql = TessQuickLook(**inputs)
+
+ # Manually set the required attributes
+ ql.pipeline = inputs["pipeline"].lower()
+ ql.sector = inputs["sector"]
+
+ # Check that the light curve was set correctly
+ assert ql.raw_lc is mock_light_curve
+
+ # Check that the flattened light curve was created
+ assert isinstance(ql.flat_lc, lk.LightCurve)
+ assert isinstance(ql.trend_lc, lk.LightCurve)
+
+ # Check that TLS was run
+ assert hasattr(ql, "tls_results")
+ assert hasattr(ql.tls_results, "period")
+
+ # Test basic attributes
+ assert ql.target_name == inputs["target_name"]
+ assert ql.flux_type == inputs["flux_type"].lower()
+ assert ql.pipeline == inputs["pipeline"].lower()
+ assert ql.sector == inputs["sector"]
+
+
+def _bare_ql(raw_lc, toi_epoch, toi_period, toi_dur, sector=1, all_sectors=None):
+ """Build a TessQuickLook without running __init__ (no network).
+
+ Only the attributes touched by ``get_transit_mask`` and
+ ``_warn_no_transits_in_sector`` are populated.
+ """
+ ql = TessQuickLook.__new__(TessQuickLook)
+ ql.raw_lc = raw_lc
+ ql.toi_epoch = toi_epoch
+ ql.toi_period = toi_period
+ ql.toi_dur = toi_dur
+ ql.sector = sector
+ ql.all_sectors = all_sectors if all_sectors is not None else [sector]
+ ql.ephem_source = "TFOP"
+ return ql
+
+
+def test_get_transit_mask_empty_when_transit_outside_baseline(mock_light_curve):
+ """A long-period ephemeris whose transit misses the sector yields no mask.
+
+ Mirrors TOI-2074 (P=177.6 d): the transit does not fall in a single
+ ~27-day sector, so the mask must be all-False rather than raising.
+ """
+ ql = _bare_ql(
+ mock_light_curve,
+ toi_epoch=np.array((1000.0, 0.01)), # far outside the 0-27 d baseline
+ toi_period=np.array((177.58, 0.005)),
+ toi_dur=np.array((0.2, 0.01)),
+ )
+
+ tmask = ql.get_transit_mask()
+
+ assert tmask.dtype == bool
+ assert len(tmask) == len(mock_light_curve.time)
+ assert tmask.sum() == 0
+
+
+def test_get_transit_mask_flags_transit_within_baseline(mock_light_curve):
+ """A short-period ephemeris inside the baseline flags in-transit cadences."""
+ ql = _bare_ql(
+ mock_light_curve,
+ toi_epoch=np.array((5.0, 0.01)), # within the 0-27 d baseline
+ toi_period=np.array((3.0, 0.005)),
+ toi_dur=np.array((0.2, 0.01)),
+ )
+
+ tmask = ql.get_transit_mask()
+
+ assert tmask.sum() > 0
+
+
+def test_warn_no_transits_in_sector_does_not_raise(mock_light_curve):
+ """Zero predicted transits warn (with guidance) instead of raising."""
+ ql = _bare_ql(
+ mock_light_curve,
+ toi_epoch=np.array((1000.0, 0.01)),
+ toi_period=np.array((177.58, 0.005)),
+ toi_dur=np.array((0.2, 0.01)),
+ sector=77,
+ all_sectors=[16, 23, 50, 77],
+ )
+
+ with patch("quicklook.tql.logger.warning") as mock_warn:
+ ql._warn_no_transits_in_sector() # must not raise
+
+ mock_warn.assert_called_once()
+ msg = mock_warn.call_args[0][0]
+ assert "sector 77" in msg
+ assert "Nearest predicted transit" in msg
+ assert "[16, 23, 50, 77]" in msg
def test_format_sector_summary_single_sector():
diff --git a/tests/test_use_priors.py b/tests/test_use_priors.py
index bad7287..93cee42 100644
--- a/tests/test_use_priors.py
+++ b/tests/test_use_priors.py
@@ -60,6 +60,7 @@ def _build_qlook(monkeypatch, use_star_priors, star_params):
from quicklook import tql as tql_mod
monkeypatch.setattr(tql_mod, "tls", _RecordingTLS)
+ monkeypatch.setattr(tql_mod, "_get_gpu_tls", lambda: None)
monkeypatch.setattr(
tql_mod,
"get_params_from_exofop",
@@ -202,14 +203,28 @@ class _FlatLC:
assert "R_star" not in kw and "M_star" not in kw
-def test_cli_use_priors_flag_wired():
- """Regression: --use_priors flag and TessQuickLook plumbing must be live
- (the flag was previously commented-out in cli/ql.py)."""
- import quicklook.cli.ql as ql_mod
+def test_cli_use_priors_flag_wired(monkeypatch):
+ """The unified Typer option reaches ``TessQuickLook``."""
+ from typer.testing import CliRunner
- src = open(ql_mod.__file__).read()
- assert '"--use_priors"' in src
- assert "use_star_priors=args.use_priors" in src
+ import quicklook.tql as tql_mod
+ from quicklook.cli.app import app
+
+ calls = {}
+
+ class FakeQuickLook:
+ def __init__(self, **kwargs):
+ calls.update(kwargs)
+
+ def plot_tql(self):
+ return None
+
+ monkeypatch.setattr(tql_mod, "TessQuickLook", FakeQuickLook)
+
+ result = CliRunner().invoke(app, ["run", "--name", "TOI-1234", "--use-priors", "--save"])
+
+ assert result.exit_code == 0, result.output
+ assert calls["use_star_priors"] is True
def test_gui_use_priors_checkbox_wired():
diff --git a/tests/web/test_app.py b/tests/web/test_app.py
index 38e351f..b10d038 100644
--- a/tests/web/test_app.py
+++ b/tests/web/test_app.py
@@ -2,6 +2,15 @@
from quicklook.app import app
+def test_import_does_not_replace_process_streams():
+ """Importing the Flask app must not retain pytest/Typer capture streams."""
+ import sys
+ import quicklook.app.app as app_module
+
+ assert sys.stdout is not app_module._tls_stdout
+ assert sys.stderr is not app_module._tls_stderr
+
+
@pytest.fixture
def client():
# app.config["TESTING"] = True
@@ -88,13 +97,26 @@ def test_debug_is_off_unless_env_var_is_set(monkeypatch):
monkeypatch.setattr(app_module.app, "run", lambda **kw: captured.update(kw))
app_module.main()
assert captured["debug"] is False
- assert "host" not in captured # loopback-only bind
+ # loopback-only bind: default host must never reach beyond localhost
+ assert captured.get("host", "127.0.0.1") == "127.0.0.1"
monkeypatch.setenv("QUICKLOOK_DEBUG", "1")
app_module.main()
assert captured["debug"] is True
+def test_run_gui_forwards_host_port_debug(monkeypatch):
+ """run_gui passes host/port/debug straight through to Flask's app.run."""
+ import quicklook.app.app as app_module
+
+ captured = {}
+ monkeypatch.setattr(app_module.app, "run", lambda **kw: captured.update(kw))
+ app_module.run_gui(host="0.0.0.0", port=8080, debug=True)
+ assert captured["host"] == "0.0.0.0"
+ assert captured["port"] == 8080
+ assert captured["debug"] is True
+
+
# --- matplotlib figure lifecycle -------------------------------------------
#
# pyplot keeps every figure in a global registry. The CLI exits per target, but
@@ -158,3 +180,26 @@ def plot_tql(**kwargs):
info = _drive_one_job(app_module, monkeypatch, tmp_path, plot_tql)
assert info["status"] == "error"
assert pl.get_fignums() == []
+
+
+def test_worker_log_preserves_interleaved_logger_and_stdout(monkeypatch, tmp_path):
+ """Logger and stdout must append without overwriting each other."""
+ from loguru import logger
+ import quicklook.app.app as app_module
+
+ def plot_tql(**kwargs):
+ quicklook_logger = logger.patch(lambda record: record.update(name="quicklook.tql"))
+ quicklook_logger.info("logger before stdout")
+ app_module._tls_stdout.write("stdout between logger messages\n")
+ app_module._tls_stdout.flush()
+ quicklook_logger.info("logger after stdout")
+
+ info = _drive_one_job(app_module, monkeypatch, tmp_path, plot_tql)
+ log_text = (tmp_path / "FIG-TEST.log").read_text()
+
+ assert info["status"] == "done"
+ assert "logger before stdout" in log_text
+ assert "stdout between logger messages" in log_text
+ assert "logger after stdout" in log_text
+ assert log_text.index("logger before stdout") < log_text.index("stdout between logger messages")
+ assert log_text.index("stdout between logger messages") < log_text.index("logger after stdout")
diff --git a/uv.lock b/uv.lock
index 397a90a..142967f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1113,6 +1113,84 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" },
]
+[[package]]
+name = "cuda-pathfinder"
+version = "1.5.6"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/53/8fc9b0cdc5b7f62746e6a01b85b6461e5ae27f871010a5fcf8fa6950766d/cuda_pathfinder-1.5.6-py3-none-any.whl", hash = "sha256:7e4c07c117b78ba1fb35dac4c444d21f3677b1b1ff56175c53a8e3025c5b43c0", size = 52972, upload-time = "2026-06-30T00:58:04.34Z" },
+]
+
+[[package]]
+name = "cuda-toolkit"
+version = "12.9.2.0"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/7e/f61db087382a2114a93abc0b38f7872f86f2c7c36839da7f9403c9044b47/cuda_toolkit-12.9.2.0-py2.py3-none-any.whl", hash = "sha256:b54b8f2fd4200090b44843d48fc5411198e8b3e47a3db56e2518ab588c0ef1e0", size = 2431, upload-time = "2026-04-14T00:40:05.972Z" },
+]
+
+[package.optional-dependencies]
+cublas = [
+ { name = "nvidia-cublas-cu12", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
+]
+cudart = [
+ { name = "nvidia-cuda-runtime-cu12", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
+]
+cufft = [
+ { name = "nvidia-cufft-cu12", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
+ { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
+]
+curand = [
+ { name = "nvidia-curand-cu12", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
+]
+cusolver = [
+ { name = "nvidia-cublas-cu12", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
+ { name = "nvidia-cusolver-cu12", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
+ { name = "nvidia-cusparse-cu12", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
+ { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
+]
+cusparse = [
+ { name = "nvidia-cusparse-cu12", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
+ { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
+]
+nvrtc = [
+ { name = "nvidia-cuda-nvrtc-cu12", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
+]
+
+[[package]]
+name = "cupy-cuda12x"
+version = "14.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cuda-pathfinder" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dd/18/8ec57a901a11d6955f90e1fbf3e04c8f26721066c99dfa25276e3e3b1f1d/cupy_cuda12x-14.1.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:909c4b8ac05eee43edfbe791522ee5d593e3504be7bd5c20e2de12b050db2a26", size = 143787561, upload-time = "2026-06-01T04:51:46.125Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/79/6a4e1562b3b6b18e93365955adfd4f66a84b60bdacf559becc0e3e0f1012/cupy_cuda12x-14.1.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:71b8de628a4a9ab24b6cc2af2162db2898e65a44a50a6d79cbc131c4f36405de", size = 132662808, upload-time = "2026-06-01T04:51:54.719Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/df/39530cffd84a00dfe98484a9ece77eed4acdc14717e1f77fa2a3e82a40dc/cupy_cuda12x-14.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:519e50b7dec2400e3fddbe9e6c4066937fd622a16774f375ab5be9d1cb1ea05e", size = 95338688, upload-time = "2026-06-01T04:51:59.685Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/65/13c173fec4923b9c4e1573344fc4a5585bf0e4efe5d9a5632e9bb18b2a31/cupy_cuda12x-14.1.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:5d4c1c74f9f7fc9de0aa5781cf3ec54f9f05143f5761e21a8798772c8eedd0be", size = 145089362, upload-time = "2026-06-01T04:52:05.643Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/5e/ccd2fea320ece269dd7237649da384cad71fbb1ba30937a1eb3311c31b77/cupy_cuda12x-14.1.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:8889cb83dbb7dbea593e60c85fcc91e21b0ccd10cd5380dfdfaac70b6bd9390a", size = 134012855, upload-time = "2026-06-01T04:52:11.526Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/59/93970d536e8401cf31d8f5602141f1c2edfc304e6d6b8702041688509509/cupy_cuda12x-14.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:e39a081fc4fe2166f95ef8be38fe2a95b2c4decb3ec991b4f26bfc9673d16b17", size = 95336905, upload-time = "2026-06-01T04:52:17.262Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/6e/290ee2d7cc4ad63d66e67acfd7ff3026f2b648dd04449a1bf88ffaa36b1e/cupy_cuda12x-14.1.1-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:7aae7d3bed37985e2aa39f0914b88ad90dbd3a6141d3e8198d73fce65859013c", size = 144383812, upload-time = "2026-06-01T04:52:23.799Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/6e/dc03c1ddc940f33b3d32803898e2fdae5c9538a2127a25f499494c84b183/cupy_cuda12x-14.1.1-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:a1138f20080489a46209291498cd12f792226d0a57d50c64a586c162a875a069", size = 133516927, upload-time = "2026-06-01T04:52:35.765Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/da/d4a8045b533af634bc791572e8c87981065e4a27b5d3e09d0d4d285742fd/cupy_cuda12x-14.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:85bebce86ffc25ecf31727b25da7b3793daf07b6fd9952704546af574d250988", size = 95238722, upload-time = "2026-06-01T04:52:46.296Z" },
+ { url = "https://files.pythonhosted.org/packages/30/90/00fe874c47207b26c9b6ac950d0cecc533b4a145491641932df17e573f3c/cupy_cuda12x-14.1.1-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:afbb3d1fa9484b0ae20d76372c5939a8c5da327e3fc8711b77b2354566cac355", size = 143920086, upload-time = "2026-06-01T04:52:51.726Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a4/c46ff91dba0dbe2a0a557974faf4c090a3159d6e7296431ca6846038d047/cupy_cuda12x-14.1.1-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:76ea35469e2aa0a8332b88f72505ea2f7871a0bc8f9b0c87184f57e47c9aa3bf", size = 133071615, upload-time = "2026-06-01T04:52:57.428Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/a0/46778424035ad3fc920d49471f079687a054f74d179142e9520014c2514e/cupy_cuda12x-14.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:64072f4139b44df38215f0519a6badc14138fa0e4bb5b2db44fe94d05f8b9c8b", size = 95219598, upload-time = "2026-06-01T04:53:02.774Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/99/d72336481264c3483b162ea128d58f80abb50009f1df82ca82905e0b8fd7/cupy_cuda12x-14.1.1-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:22d0ff2755a7f29cb225d1d5fb979a73428c5534ea0bca91b0c02698e9948f84", size = 143788629, upload-time = "2026-06-01T04:53:09.404Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/77/c43a67e6809e03780d88caf690fa44a8b3152db2d8f848714bec327c9881/cupy_cuda12x-14.1.1-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:1059581507343e7cf6231facce30932a195c7aad4fa7771d00e4a252683915a1", size = 132406367, upload-time = "2026-06-01T04:53:16.232Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/dc/96cd37de6da41239e02fc7f17e3364d60f99bd6816673d622916a06113ec/cupy_cuda12x-14.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:e707e0eceee174d323be21652e87bb97be982e6966b5dc241756307df42842aa", size = 95793971, upload-time = "2026-06-01T04:53:21.478Z" },
+ { url = "https://files.pythonhosted.org/packages/20/c6/0ddec1be851de546e883ae3da5f03c1ea69738628b38234dce4362b5e38b/cupy_cuda12x-14.1.1-cp314-cp314t-manylinux2014_aarch64.whl", hash = "sha256:e09897636b7468a90efa1152109f0b19ba49ebc9a423d5dbd4682ed589e57843", size = 144093057, upload-time = "2026-06-01T04:53:28.647Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/80/5e05de89ba61df072aab6f8a6ee3ffeec57db68a0a456825b3b4ce608426/cupy_cuda12x-14.1.1-cp314-cp314t-manylinux2014_x86_64.whl", hash = "sha256:238080487174268d0f09770fe518de7c5b206527bef5c6792aef7ba0626a1c48", size = 132635338, upload-time = "2026-06-01T04:53:35.229Z" },
+]
+
+[package.optional-dependencies]
+ctk = [
+ { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "curand", "cusolver", "cusparse", "nvrtc"] },
+]
+
[[package]]
name = "cycler"
version = "0.12.1"
@@ -1556,6 +1634,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" },
]
+[[package]]
+name = "gputls"
+version = "0.5.1"
+source = { git = "https://github.com/jpdeleon/GTLS.git#c8cd6209c3add315f9620e0c4acffdbd942930c1" }
+dependencies = [
+ { name = "batman-package" },
+ { name = "numba" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "pynvml" },
+ { name = "setuptools", marker = "python_full_version >= '3.12'" },
+ { name = "tqdm" },
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -3307,6 +3399,109 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" },
]
+[[package]]
+name = "nvidia-cublas-cu12"
+version = "12.9.2.10"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-cuda-nvrtc-cu12" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/a2/c96163a0fff1839c0c9548bbdeae7b853b867009e33b9b9264adc238b1cf/nvidia_cublas_cu12-12.9.2.10-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:5572131a59c3eebeeb1c4c8144f772d49372c20124916e072a0e3fc30df421d5", size = 575012079, upload-time = "2026-04-08T18:51:47.303Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/c0/0a517bfe63ccd3b92eb254d264e28fca3c7cab75d07daea315250fb1bf73/nvidia_cublas_cu12-12.9.2.10-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:e4f53a8ca8c5d6e8c492d0d0a3d565ecb59a751b19cfdaa4f6da0ab2104c1702", size = 581240110, upload-time = "2026-04-08T18:52:31.532Z" },
+ { url = "https://files.pythonhosted.org/packages/20/e2/fc9a0e985249d873150276d5afb02e39a66817fedbf1a385724393e505ed/nvidia_cublas_cu12-12.9.2.10-py3-none-win_amd64.whl", hash = "sha256:623f43027d40d44ceadf0043f002bd25cf353e8f13ce90b9a87057019f560661", size = 553162896, upload-time = "2026-04-08T18:53:10.035Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-nvrtc-cu12"
+version = "12.9.86"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b8/85/e4af82cc9202023862090bfca4ea827d533329e925c758f0cde964cb54b7/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:210cf05005a447e29214e9ce50851e83fc5f4358df8b453155d5e1918094dcb4", size = 89568129, upload-time = "2025-06-05T20:02:41.973Z" },
+ { url = "https://files.pythonhosted.org/packages/64/eb/c2295044b8f3b3b08860e2f6a912b702fc92568a167259df5dddb78f325e/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:096d4de6bda726415dfaf3198d4f5c522b8e70139c97feef5cd2ca6d4cd9cead", size = 44528905, upload-time = "2025-06-05T20:02:29.754Z" },
+ { url = "https://files.pythonhosted.org/packages/52/de/823919be3b9d0ccbf1f784035423c5f18f4267fb0123558d58b813c6ec86/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-win_amd64.whl", hash = "sha256:72972ebdcf504d69462d3bcd67e7b81edd25d0fb85a2c46d3ea3517666636349", size = 76408187, upload-time = "2025-06-05T20:12:27.819Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-runtime-cu12"
+version = "12.9.79"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bc/e0/0279bd94539fda525e0c8538db29b72a5a8495b0c12173113471d28bce78/nvidia_cuda_runtime_cu12-12.9.79-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83469a846206f2a733db0c42e223589ab62fd2fabac4432d2f8802de4bded0a4", size = 3515012, upload-time = "2025-06-05T20:00:35.519Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/46/a92db19b8309581092a3add7e6fceb4c301a3fd233969856a8cbf042cd3c/nvidia_cuda_runtime_cu12-12.9.79-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25bba2dfb01d48a9b59ca474a1ac43c6ebf7011f1b0b8cc44f54eb6ac48a96c3", size = 3493179, upload-time = "2025-06-05T20:00:53.735Z" },
+ { url = "https://files.pythonhosted.org/packages/59/df/e7c3a360be4f7b93cee39271b792669baeb3846c58a4df6dfcf187a7ffab/nvidia_cuda_runtime_cu12-12.9.79-py3-none-win_amd64.whl", hash = "sha256:8e018af8fa02363876860388bd10ccb89eb9ab8fb0aa749aaf58430a9f7c4891", size = 3591604, upload-time = "2025-06-05T20:11:17.036Z" },
+]
+
+[[package]]
+name = "nvidia-cufft-cu12"
+version = "11.4.1.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-nvjitlink-cu12" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/2b/76445b0af890da61b501fde30650a1a4bd910607261b209cccb5235d3daa/nvidia_cufft_cu12-11.4.1.4-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1a28c9b12260a1aa7a8fd12f5ebd82d027963d635ba82ff39a1acfa7c4c0fbcf", size = 200822453, upload-time = "2025-06-05T20:05:27.889Z" },
+ { url = "https://files.pythonhosted.org/packages/95/f4/61e6996dd20481ee834f57a8e9dca28b1869366a135e0d42e2aa8493bdd4/nvidia_cufft_cu12-11.4.1.4-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c67884f2a7d276b4b80eb56a79322a95df592ae5e765cf1243693365ccab4e28", size = 200877592, upload-time = "2025-06-05T20:05:45.862Z" },
+ { url = "https://files.pythonhosted.org/packages/20/ee/29955203338515b940bd4f60ffdbc073428f25ef9bfbce44c9a066aedc5c/nvidia_cufft_cu12-11.4.1.4-py3-none-win_amd64.whl", hash = "sha256:8e5bfaac795e93f80611f807d42844e8e27e340e0cde270dcb6c65386d795b80", size = 200067309, upload-time = "2025-06-05T20:13:59.762Z" },
+]
+
+[[package]]
+name = "nvidia-curand-cu12"
+version = "10.3.10.19"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/1c/2a45afc614d99558d4a773fa740d8bb5471c8398eeed925fc0fcba020173/nvidia_curand_cu12-10.3.10.19-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:de663377feb1697e1d30ed587b07d5721fdd6d2015c738d7528a6002a6134d37", size = 68292066, upload-time = "2025-05-01T19:39:13.595Z" },
+ { url = "https://files.pythonhosted.org/packages/31/44/193a0e171750ca9f8320626e8a1f2381e4077a65e69e2fb9708bd479e34a/nvidia_curand_cu12-10.3.10.19-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:49b274db4780d421bd2ccd362e1415c13887c53c214f0d4b761752b8f9f6aa1e", size = 68295626, upload-time = "2025-05-01T19:39:38.885Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/98/1bd66fd09cbe1a5920cb36ba87029d511db7cca93979e635fd431ad3b6c0/nvidia_curand_cu12-10.3.10.19-py3-none-win_amd64.whl", hash = "sha256:e8129e6ac40dc123bd948e33d3e11b4aa617d87a583fa2f21b3210e90c743cde", size = 68774847, upload-time = "2025-05-01T19:48:52.93Z" },
+]
+
+[[package]]
+name = "nvidia-cusolver-cu12"
+version = "11.7.5.82"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-cublas-cu12" },
+ { name = "nvidia-cusparse-cu12" },
+ { name = "nvidia-nvjitlink-cu12" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/99/686ff9bf3a82a531c62b1a5c614476e8dfa24a9d89067aeedf3592ee4538/nvidia_cusolver_cu12-11.7.5.82-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:62efa83e4ace59a4c734d052bb72158e888aa7b770e1a5f601682f16fe5b4fd2", size = 337869834, upload-time = "2025-06-05T20:06:53.125Z" },
+ { url = "https://files.pythonhosted.org/packages/33/40/79b0c64d44d6c166c0964ec1d803d067f4a145cca23e23925fd351d0e642/nvidia_cusolver_cu12-11.7.5.82-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:15da72d1340d29b5b3cf3fd100e3cd53421dde36002eda6ed93811af63c40d88", size = 338117415, upload-time = "2025-06-05T20:07:16.809Z" },
+ { url = "https://files.pythonhosted.org/packages/32/5d/feb7f86b809f89b14193beffebe24cf2e4bf7af08372ab8cdd34d19a65a0/nvidia_cusolver_cu12-11.7.5.82-py3-none-win_amd64.whl", hash = "sha256:77666337237716783c6269a658dea310195cddbd80a5b2919b1ba8735cec8efd", size = 326215953, upload-time = "2025-06-05T20:14:41.76Z" },
+]
+
+[[package]]
+name = "nvidia-cusparse-cu12"
+version = "12.5.10.65"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-nvjitlink-cu12" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5e/6f/8710fbd17cdd1d0fc3fea7d36d5b65ce1933611c31e1861da330206b253a/nvidia_cusparse_cu12-12.5.10.65-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:221c73e7482dd93eda44e65ce567c031c07e2f93f6fa0ecd3ba876a195023e83", size = 366359408, upload-time = "2025-06-05T20:07:42.501Z" },
+ { url = "https://files.pythonhosted.org/packages/12/46/b0fd4b04f86577921feb97d8e2cf028afe04f614d17fb5013de9282c9216/nvidia_cusparse_cu12-12.5.10.65-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:73060ce019ac064a057267c585bf1fd5a353734151f87472ff02b2c5c9984e78", size = 366465088, upload-time = "2025-06-05T20:08:20.413Z" },
+ { url = "https://files.pythonhosted.org/packages/73/ef/063500c25670fbd1cbb0cd3eb7c8a061585b53adb4dd8bf3492bb49b0df3/nvidia_cusparse_cu12-12.5.10.65-py3-none-win_amd64.whl", hash = "sha256:9e487468a22a1eaf1fbd1d2035936a905feb79c4ce5c2f67626764ee4f90227c", size = 362504719, upload-time = "2025-06-05T20:15:17.947Z" },
+]
+
+[[package]]
+name = "nvidia-ml-py"
+version = "13.610.43"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f0/b5/a8fbc356f768fa5c9cfd646668fd7d34bf55bdd1c6e20754642a64d930d4/nvidia_ml_py-13.610.43.tar.gz", hash = "sha256:65437eb73d68d0c62c931ca4d45038472faff03bd0b8729abba4b899f70d60f2", size = 52109, upload-time = "2026-06-01T18:54:08.829Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/23/45/caa600acfab94560807a20a64b5830d2cd3c3202b7f1328644d70b7d6bd8/nvidia_ml_py-13.610.43-py3-none-any.whl", hash = "sha256:f13c72698edef492f985cc225f14faafe68ae065a2e407f45bdf6f4b9b43fde8", size = 53163, upload-time = "2026-06-01T18:54:07.704Z" },
+]
+
+[[package]]
+name = "nvidia-nvjitlink-cu12"
+version = "12.9.86"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/0c/c75bbfb967457a0b7670b8ad267bfc4fffdf341c074e0a80db06c24ccfd4/nvidia_nvjitlink_cu12-12.9.86-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:e3f1171dbdc83c5932a45f0f4c99180a70de9bd2718c1ab77d14104f6d7147f9", size = 39748338, upload-time = "2025-06-05T20:10:25.613Z" },
+ { url = "https://files.pythonhosted.org/packages/97/bc/2dcba8e70cf3115b400fef54f213bcd6715a3195eba000f8330f11e40c45/nvidia_nvjitlink_cu12-12.9.86-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:994a05ef08ef4b0b299829cde613a424382aff7efb08a7172c1fa616cc3af2ca", size = 39514880, upload-time = "2025-06-05T20:10:04.89Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/7e/2eecb277d8a98184d881fb98a738363fd4f14577a4d2d7f8264266e82623/nvidia_nvjitlink_cu12-12.9.86-py3-none-win_amd64.whl", hash = "sha256:cc6fcec260ca843c10e34c936921a1c426b351753587fdd638e8cff7b16bb9db", size = 35584936, upload-time = "2025-06-05T20:16:08.525Z" },
+]
+
[[package]]
name = "overrides"
version = "7.7.0"
@@ -3848,6 +4043,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
+[[package]]
+name = "pynvml"
+version = "13.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-ml-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5c/57/da7dc63a79f59e082e26a66ac02d87d69ea316b35b35b7a00d82f3ce3d2f/pynvml-13.0.1.tar.gz", hash = "sha256:1245991d9db786b4d2f277ce66869bd58f38ac654e38c9397d18f243c8f6e48f", size = 35226, upload-time = "2025-09-05T20:33:25.377Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d7/4a/cac76c174bb439a0c46c9a4413fcbea5c6cabfb01879f7bbdb9fdfaed76c/pynvml-13.0.1-py3-none-any.whl", hash = "sha256:e2b20e0a501eeec951e2455b7ab444759cf048e0e13a57b08049fa2775266aa8", size = 28810, upload-time = "2025-09-05T20:33:24.13Z" },
+]
+
[[package]]
name = "pyparsing"
version = "3.3.2"
@@ -4183,6 +4390,10 @@ dev = [
{ name = "ruff" },
{ name = "tox" },
]
+gpu = [
+ { name = "cupy-cuda12x", extra = ["ctk"] },
+ { name = "gputls" },
+]
gui = [
{ name = "flask" },
{ name = "flask-sock" },
@@ -4197,9 +4408,11 @@ notebooks = [
[package.metadata]
requires-dist = [
{ name = "astroplan", specifier = ">=0.10" },
+ { name = "cupy-cuda12x", extras = ["ctk"], marker = "extra == 'gpu'" },
{ name = "flask" },
{ name = "flask", marker = "extra == 'gui'" },
{ name = "flask-sock", marker = "extra == 'gui'" },
+ { name = "gputls", marker = "extra == 'gpu'", git = "https://github.com/jpdeleon/GTLS.git" },
{ name = "h5py", specifier = ">=3.10" },
{ name = "ipykernel", marker = "extra == 'notebooks'" },
{ name = "jupyter", marker = "extra == 'notebooks'" },
@@ -4222,7 +4435,7 @@ requires-dist = [
{ name = "typer", specifier = ">=0.12" },
{ name = "wotan", specifier = ">=1.10" },
]
-provides-extras = ["dev", "gui", "notebooks"]
+provides-extras = ["dev", "gui", "gpu", "notebooks"]
[[package]]
name = "referencing"