From 450e2c61ca79325d630697fc3b92a62a81890f51 Mon Sep 17 00:00:00 2001 From: jerome de leon Date: Mon, 13 Jul 2026 19:36:38 +0900 Subject: [PATCH 01/10] feat: unify CLI under a single `quicklook` Typer entrypoint with a gui subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `quicklook` console script (with `ql` as a short alias) that groups every subcommand — `run`, `read-tls`, `rank-tls`, and the new `gui` — so the package runs as `uv run quicklook gui`, `uv run quicklook run --name ...`, etc. - Add a `gui` subcommand that launches the Flask web GUI with `--host`, `--port`, and `--debug` options. The heavy Flask import stays deferred inside the command so `--help` remains fast. - Refactor `quicklook/app/app.py` `main()` into `run_gui(host, port, debug)` so the launcher is reusable; `ql-gui` stays as an alias. Loopback-only bind and opt-in debugger behavior are preserved (an explicit `--debug` forces it on, otherwise `QUICKLOOK_DEBUG` is consulted). - Repoint the `ql` console script at the unified Typer app. The old `quicklook.cli.ql:main` shim unconditionally injected `run`, which broke `ql read-tls`, `ql rank-tls`, and top-level `ql --help`. Drop that dead shim, keeping `sanitize_target_name` in the module. - Tests: cover `gui --help`, option forwarding to `run_gui`, the `--debug` flag, and `run_gui` host/port/debug passthrough. Update the loopback-bind assertion to match the now-explicit default host. - README: document `uv run quicklook ` usage and the `gui` subcommand. Also folds in a pre-existing GUI worker logging fix (open the per-job log in append mode so Loguru's separate append-mode descriptor no longer overwrites interleaved stdout/stderr) and its regression test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011jyqtiWKpNN5yMbeMPHJfC --- README.md | 40 +++++++++++++++++++++++++--------------- pyproject.toml | 3 ++- quicklook/app/app.py | 33 ++++++++++++++++++++++++++++----- quicklook/cli/app.py | 26 ++++++++++++++++++++++++++ quicklook/cli/ql.py | 18 ++++-------------- tests/test_cli.py | 39 +++++++++++++++++++++++++++++++++++++++ tests/web/test_app.py | 38 +++++++++++++++++++++++++++++++++++++- 7 files changed, 161 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index f788e45..5a80d47 100755 --- a/README.md +++ b/README.md @@ -57,15 +57,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 +133,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. ![QuickLook Web GUI](docs/img/ql-gui.png) -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 +177,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..2367776 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,7 +97,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..f0e4a8c 100644 --- a/quicklook/app/app.py +++ b/quicklook/app/app.py @@ -281,7 +281,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 +1397,29 @@ 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") + app.run(host=host, port=port, debug=debug, threaded=True) + + 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/tests/test_cli.py b/tests/test_cli.py index 245f211..cefca5d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -108,3 +108,42 @@ def test_run_with_save_skips_headless_check(): if had_display is not None: os.environ["DISPLAY"] = had_display logger.enable("quicklook") + + +def test_gui_help_succeeds(): + result = runner.invoke(app, ["gui", "--help"]) + assert result.exit_code == 0 + assert "web" in result.output.lower() or "gui" in result.output.lower() + assert "--host" in result.output + assert "--port" in result.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/web/test_app.py b/tests/web/test_app.py index 38e351f..3ad0cf1 100644 --- a/tests/web/test_app.py +++ b/tests/web/test_app.py @@ -88,13 +88,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 +171,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") From 9f93add4c0233f9a5d9185d0c9a44b162ff59d4b Mon Sep 17 00:00:00 2001 From: jerome de leon Date: Mon, 13 Jul 2026 19:47:41 +0900 Subject: [PATCH 02/10] feat: warn instead of raise when a known ephemeris predicts no transit in-sector For long-period planets the transit often falls outside the ~27-day TESS sector that was downloaded (e.g. TOI-2074, P=177.6 d), so there is simply nothing to mask. Previously `TessQuickLook` raised `PipelineError` ("No masked transits") and aborted the whole quicklook, even though flattening, the TLS search, and the plots can all still run. - Replace the raise with `_warn_no_transits_in_sector()`, which logs a warning reporting the sector's BTJD coverage, the nearest predicted transit (and how far outside coverage it lands), and suggests other available sectors. - Only apply ephemeris masking when there is at least one in-transit cadence. - Tests: an out-of-baseline ephemeris yields an all-False mask, an in-baseline ephemeris flags cadences, and the warning path emits guidance without raising. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011jyqtiWKpNN5yMbeMPHJfC --- quicklook/tql.py | 44 ++++++++++++++++++++++++++--- tests/test_tql.py | 72 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/quicklook/tql.py b/quicklook/tql.py index 1ea7e9e..c5e7ac2 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 @@ -155,10 +155,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..." @@ -1170,6 +1175,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_tql.py b/tests/test_tql.py index 15ee926..0b6f36f 100755 --- a/tests/test_tql.py +++ b/tests/test_tql.py @@ -177,6 +177,78 @@ def test_with_mock_light_curve(mock_light_curve, planet_inputs): 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(): assert TessQuickLook._format_sector_summary(56, [56]) == "56" From d4092f06ccded9c840b6e6dc7c13adca156603e3 Mon Sep 17 00:00:00 2001 From: jerome de leon Date: Mon, 13 Jul 2026 20:59:28 +0900 Subject: [PATCH 03/10] feat: add optional GPU TLS support --- pyproject.toml | 5 +++ quicklook/tql.py | 77 +++++++++++++++++++++++++++++++- tests/test_gpu_tls.py | 52 ++++++++++++++++++++++ tests/test_use_priors.py | 1 + uv.lock | 96 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 tests/test_gpu_tls.py diff --git a/pyproject.toml b/pyproject.toml index 2367776..c3a8e80 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,12 +63,17 @@ dev = [ gui = ["flask", "flask-sock", "pytest-flask"] +gpu = ["gputls[cuda12]"] + notebooks = [ "ipykernel", "jupyter", "nbconvert" ] +[tool.uv.sources] +gputls = { path = "../ext_tools/GTLS", editable = true } + [project.urls] Homepage = "https://github.com/jpdeleon/quicklook" diff --git a/quicklook/tql.py b/quicklook/tql.py index c5e7ac2..61569e8 100755 --- a/quicklook/tql.py +++ b/quicklook/tql.py @@ -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, @@ -973,12 +1041,17 @@ 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() + tls_engine = gpu_tls or tls + logger.info("Running GTLS on GPU" if gpu_tls else "Running TLS on CPU") + model = tls_engine( self.flat_lc.time.value, self.flat_lc.flux.value, flux_err, verbose=self.verbose, - ).power(**power_kwargs) + ) + result = model.power(**power_kwargs) + self.tls_results = _adapt_gtls_result(result, model) if gpu_tls else result def _stellar_prior_kwargs(self): """Pull R_star, M_star (and ±1σ bounds) from ExoFOP for the TLS prior. diff --git a/tests/test_gpu_tls.py b/tests/test_gpu_tls.py new file mode 100644 index 0000000..476e1b6 --- /dev/null +++ b/tests/test_gpu_tls.py @@ -0,0 +1,52 @@ +import sys +import types + +import numpy as np + +from quicklook import tql + + +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_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_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_use_priors.py b/tests/test_use_priors.py index bad7287..35b1d68 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", diff --git a/uv.lock b/uv.lock index 397a90a..4de27f9 100644 --- a/uv.lock +++ b/uv.lock @@ -1113,6 +1113,43 @@ 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 = "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]] name = "cycler" version = "0.12.1" @@ -1556,6 +1593,38 @@ 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 = { editable = "../ext_tools/GTLS" } +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.optional-dependencies] +cuda12 = [ + { name = "cupy-cuda12x" }, +] + +[package.metadata] +requires-dist = [ + { name = "batman-package" }, + { name = "cupy-cuda11x", marker = "extra == 'cuda11'" }, + { name = "cupy-cuda12x", marker = "extra == 'cuda12'" }, + { name = "numba" }, + { name = "numpy" }, + { name = "pynvml" }, + { name = "setuptools", marker = "python_full_version >= '3.12'" }, + { name = "tqdm" }, +] +provides-extras = ["cuda11", "cuda12"] + [[package]] name = "h11" version = "0.16.0" @@ -3307,6 +3376,15 @@ 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-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 = "overrides" version = "7.7.0" @@ -3848,6 +3926,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 +4273,9 @@ dev = [ { name = "ruff" }, { name = "tox" }, ] +gpu = [ + { name = "gputls", extra = ["cuda12"] }, +] gui = [ { name = "flask" }, { name = "flask-sock" }, @@ -4200,6 +4293,7 @@ requires-dist = [ { name = "flask" }, { name = "flask", marker = "extra == 'gui'" }, { name = "flask-sock", marker = "extra == 'gui'" }, + { name = "gputls", extras = ["cuda12"], marker = "extra == 'gpu'", editable = "../ext_tools/GTLS" }, { name = "h5py", specifier = ">=3.10" }, { name = "ipykernel", marker = "extra == 'notebooks'" }, { name = "jupyter", marker = "extra == 'notebooks'" }, @@ -4222,7 +4316,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" From f28871918134b02a41bf3bf7fae0c98f91598a35 Mon Sep 17 00:00:00 2001 From: jerome de leon Date: Mon, 13 Jul 2026 21:03:41 +0900 Subject: [PATCH 04/10] docs: clarify optional GPU TLS fallback --- README.md | 7 +++++++ tests/test_gpu_tls.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/README.md b/README.md index 5a80d47..e19d0a5 100755 --- a/README.md +++ b/README.md @@ -42,6 +42,9 @@ pip install -U quicklook-package # Web GUI pip install -U "quicklook-package[gui]" +# GPU-accelerated transit search (CUDA 12) +uv sync --extra gpu + # Jupyter notebooks pip install -U "quicklook-package[notebooks]" @@ -49,6 +52,10 @@ pip install -U "quicklook-package[notebooks]" pip install -U "quicklook-package[dev]" ``` +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. + ## Try it on Google Colab Open In Colab diff --git a/tests/test_gpu_tls.py b/tests/test_gpu_tls.py index 476e1b6..5d57c57 100644 --- a/tests/test_gpu_tls.py +++ b/tests/test_gpu_tls.py @@ -20,6 +20,13 @@ def test_cpu_tls_is_fallback_when_no_cuda_device_is_visible(monkeypatch): 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") From b54559dd88153dcf3536fb602bda8811711d08da Mon Sep 17 00:00:00 2001 From: jerome de leon Date: Mon, 13 Jul 2026 21:08:37 +0900 Subject: [PATCH 05/10] fix: install CUDA toolkit headers with GPU extra --- README.md | 2 +- pyproject.toml | 2 +- tests/test_gpu_tls.py | 7 ++ uv.lock | 146 ++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 148 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e19d0a5..bad7d94 100755 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ pip install -U quicklook-package # Web GUI pip install -U "quicklook-package[gui]" -# GPU-accelerated transit search (CUDA 12) +# GPU-accelerated transit search (CUDA 12, including toolkit headers) uv sync --extra gpu # Jupyter notebooks diff --git a/pyproject.toml b/pyproject.toml index c3a8e80..5dbc314 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ dev = [ gui = ["flask", "flask-sock", "pytest-flask"] -gpu = ["gputls[cuda12]"] +gpu = ["gputls", "cupy-cuda12x[ctk]"] notebooks = [ "ipykernel", diff --git a/tests/test_gpu_tls.py b/tests/test_gpu_tls.py index 5d57c57..5ca8315 100644 --- a/tests/test_gpu_tls.py +++ b/tests/test_gpu_tls.py @@ -1,11 +1,18 @@ 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) diff --git a/uv.lock b/uv.lock index 4de27f9..0b1bd02 100644 --- a/uv.lock +++ b/uv.lock @@ -1121,6 +1121,42 @@ 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" @@ -1150,6 +1186,11 @@ wheels = [ { 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" @@ -1607,11 +1648,6 @@ dependencies = [ { name = "tqdm" }, ] -[package.optional-dependencies] -cuda12 = [ - { name = "cupy-cuda12x" }, -] - [package.metadata] requires-dist = [ { name = "batman-package" }, @@ -3376,6 +3412,90 @@ 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" @@ -3385,6 +3505,16 @@ 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" @@ -4274,7 +4404,8 @@ dev = [ { name = "tox" }, ] gpu = [ - { name = "gputls", extra = ["cuda12"] }, + { name = "cupy-cuda12x", extra = ["ctk"] }, + { name = "gputls" }, ] gui = [ { name = "flask" }, @@ -4290,10 +4421,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", extras = ["cuda12"], marker = "extra == 'gpu'", editable = "../ext_tools/GTLS" }, + { name = "gputls", marker = "extra == 'gpu'", editable = "../ext_tools/GTLS" }, { name = "h5py", specifier = ">=3.10" }, { name = "ipykernel", marker = "extra == 'notebooks'" }, { name = "jupyter", marker = "extra == 'notebooks'" }, From f1e38d69f3306098f87b35cc8b75fe08a3c58a6c Mon Sep 17 00:00:00 2001 From: jerome de leon Date: Mon, 13 Jul 2026 21:17:36 +0900 Subject: [PATCH 06/10] fix: fall back when GPU TLS initialization fails --- README.md | 2 +- quicklook/tql.py | 20 ++++++++++++++------ tests/test_gpu_tls.py | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index bad7d94..389ec40 100755 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ pip install -U "quicklook-package[dev]" 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. +when GTLS or a GPU is unavailable, or when GPU execution fails to initialize. ## Try it on Google Colab diff --git a/quicklook/tql.py b/quicklook/tql.py index 61569e8..2611e17 100755 --- a/quicklook/tql.py +++ b/quicklook/tql.py @@ -1042,16 +1042,24 @@ def run_tls(self): else: logger.info("use_priors=False: TLS will use Sun-like defaults (R_star=1, M_star=1)") gpu_tls = _get_gpu_tls() - tls_engine = gpu_tls or tls - logger.info("Running GTLS on GPU" if gpu_tls else "Running TLS on CPU") - model = tls_engine( + engine_args = ( self.flat_lc.time.value, self.flat_lc.flux.value, flux_err, - verbose=self.verbose, ) - result = model.power(**power_kwargs) - self.tls_results = _adapt_gtls_result(result, model) if gpu_tls else result + 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. diff --git a/tests/test_gpu_tls.py b/tests/test_gpu_tls.py index 5ca8315..b70d5d9 100644 --- a/tests/test_gpu_tls.py +++ b/tests/test_gpu_tls.py @@ -43,6 +43,47 @@ def 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, From 731e93692498d015cdd29c76fe248fb9309c77f7 Mon Sep 17 00:00:00 2001 From: jerome de leon Date: Mon, 13 Jul 2026 22:15:48 +0900 Subject: [PATCH 07/10] Use GitHub source for GTLS --- README.md | 46 +++++++++++++++++++++++++++++++--------------- pyproject.toml | 2 +- uv.lock | 17 ++--------------- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 389ec40..7bd22ea 100755 --- a/README.md +++ b/README.md @@ -26,32 +26,48 @@ 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 +# 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 +``` -```bash -# Web GUI -pip install -U "quicklook-package[gui]" +Run commands from a repository installation with `uv run`, for example +`uv run quicklook --help`. -# GPU-accelerated transit search (CUDA 12, including toolkit headers) -uv sync --extra gpu +
-# 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. diff --git a/pyproject.toml b/pyproject.toml index 5dbc314..f9da425 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,7 @@ notebooks = [ ] [tool.uv.sources] -gputls = { path = "../ext_tools/GTLS", editable = true } +gputls = { git = "https://github.com/jpdeleon/GTLS.git" } [project.urls] Homepage = "https://github.com/jpdeleon/quicklook" diff --git a/uv.lock b/uv.lock index 0b1bd02..142967f 100644 --- a/uv.lock +++ b/uv.lock @@ -1637,7 +1637,7 @@ wheels = [ [[package]] name = "gputls" version = "0.5.1" -source = { editable = "../ext_tools/GTLS" } +source = { git = "https://github.com/jpdeleon/GTLS.git#c8cd6209c3add315f9620e0c4acffdbd942930c1" } dependencies = [ { name = "batman-package" }, { name = "numba" }, @@ -1648,19 +1648,6 @@ dependencies = [ { name = "tqdm" }, ] -[package.metadata] -requires-dist = [ - { name = "batman-package" }, - { name = "cupy-cuda11x", marker = "extra == 'cuda11'" }, - { name = "cupy-cuda12x", marker = "extra == 'cuda12'" }, - { name = "numba" }, - { name = "numpy" }, - { name = "pynvml" }, - { name = "setuptools", marker = "python_full_version >= '3.12'" }, - { name = "tqdm" }, -] -provides-extras = ["cuda11", "cuda12"] - [[package]] name = "h11" version = "0.16.0" @@ -4425,7 +4412,7 @@ requires-dist = [ { name = "flask" }, { name = "flask", marker = "extra == 'gui'" }, { name = "flask-sock", marker = "extra == 'gui'" }, - { name = "gputls", marker = "extra == 'gpu'", editable = "../ext_tools/GTLS" }, + { 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'" }, From 667ebb8304364a6fa442caa745b45725b2583f9e Mon Sep 17 00:00:00 2001 From: jerome de leon Date: Tue, 14 Jul 2026 08:06:17 +0900 Subject: [PATCH 08/10] fix: stabilize unified CLI workflow tests --- quicklook/app/app.py | 24 +++++++++++++++++++++--- tests/test_cli.py | 37 +++++++++++++++++++++---------------- tests/test_phase_xlim.py | 27 +++++++++++++++++++++------ tests/test_use_priors.py | 30 ++++++++++++++++++++++-------- tests/web/test_app.py | 9 +++++++++ 5 files changed, 94 insertions(+), 33 deletions(-) diff --git a/quicklook/app/app.py b/quicklook/app/app.py index f0e4a8c..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 # --------------------------------------------------------------------------- @@ -1414,7 +1415,24 @@ def run_gui(host="127.0.0.1", port=5000, debug=None): """ if debug is None: debug = os.environ.get("QUICKLOOK_DEBUG", "").lower() in ("1", "true", "yes") - app.run(host=host, port=port, debug=debug, threaded=True) + + # 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(): diff --git a/tests/test_cli.py b/tests/test_cli.py index cefca5d..bf4b52d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -91,23 +91,28 @@ 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(): 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_use_priors.py b/tests/test_use_priors.py index 35b1d68..93cee42 100644 --- a/tests/test_use_priors.py +++ b/tests/test_use_priors.py @@ -203,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 - - src = open(ql_mod.__file__).read() - assert '"--use_priors"' in src - assert "use_star_priors=args.use_priors" in src +def test_cli_use_priors_flag_wired(monkeypatch): + """The unified Typer option reaches ``TessQuickLook``.""" + from typer.testing import CliRunner + + 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 3ad0cf1..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 From eeb47efc052ea31bde7ecd4147c848f04d37e13b Mon Sep 17 00:00:00 2001 From: jerome de leon Date: Tue, 14 Jul 2026 08:53:42 +0900 Subject: [PATCH 09/10] test: normalize colored CLI help output --- tests/test_cli.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index bf4b52d..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 @@ -117,10 +118,11 @@ def plot_tql(self): def test_gui_help_succeeds(): result = runner.invoke(app, ["gui", "--help"]) + output = unstyle(result.output) assert result.exit_code == 0 - assert "web" in result.output.lower() or "gui" in result.output.lower() - assert "--host" in result.output - assert "--port" in result.output + 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): From f105976a13667a4fa18d297872854dac435f2f7b Mon Sep 17 00:00:00 2001 From: jerome de leon Date: Tue, 14 Jul 2026 10:25:34 +0900 Subject: [PATCH 10/10] ci: remove external-service stalls and update actions --- .github/workflows/build-and-test.yml | 10 ++--- .github/workflows/release.yml | 4 +- tests/test_tql.py | 66 ++++++++++++++++------------ 3 files changed, 45 insertions(+), 35 deletions(-) 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/tests/test_tql.py b/tests/test_tql.py index 0b6f36f..0c0a512 100755 --- a/tests/test_tql.py +++ b/tests/test_tql.py @@ -145,36 +145,46 @@ 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):