Skip to content
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ cython_debug/
# GUI runtime outputs (generated by ql-gui)
quicklook/app/static/outputs/

# Local test run artifacts (HDF5 outputs produced by manual TLS runs in tests/)
tests/*.h5

# MkDocs build output
site/

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Although `quicklook` is optimized to find transiting exoplanets, it can also det
## Features

- **Multi-pipeline support** -- SPOC, TESS-SPOC, QLP, CDIPS, PATHOS, TGLC, TASOC
- **Flux / light-curve type** -- PDCSAP or SAP for SPOC; aperture or PSF photometry for TGLC, with an automatic best-quality default
- **Automated detrending** -- biweight, cosine, median, GP, and other [wotan](https://github.com/hippke/wotan) methods
- **Stellar rotation** -- Generalized Lomb-Scargle (GLS) periodogram
- **Transit detection** -- Transit Least Squares (TLS) periodogram
Expand Down Expand Up @@ -92,6 +93,12 @@ print(f"TLS period: {ql.tls_results.period:.4f} days")
print(f"TLS SDE: {ql.tls_results.SDE:.1f}")
```

For **SPOC**, `flux_type` selects `"pdcsap"` or `"sap"`. For **TGLC**, the same
argument selects the photometry method -- `"aperture"` or `"psf"`; any other
value (including the default) uses automatic selection of the less-contaminated,
lower-scatter light curve. TGLC light curves absent from MAST are extracted
locally via effective-PSF (ePSF) photometry.

### Web GUI

```bash
Expand Down
10 changes: 10 additions & 0 deletions README_DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,13 @@ def test_tql_runtime(benchmark, planet_inputs):
```bash
tox -r
```

- **Check minimum Python required by dependencies**:

`check_pyproject_deps.py` reads `pyproject.toml`, looks up each dependency's
`Requires-Python` in the currently installed environment, and prints the
overall minimum Python version needed.

```bash
python check_pyproject_deps.py
```
88 changes: 88 additions & 0 deletions check_pyproject_deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import sys
import os
from packaging.version import parse as parse_version
from packaging.specifiers import SpecifierSet

# For Python ≥3.11
try:
import tomllib
except ImportError:
import tomli as tomllib # pip install tomli

try:
from importlib.metadata import distributions
except ImportError:
from importlib_metadata import distributions # pip install importlib-metadata


def read_pyproject(file_path="pyproject.toml"):
with open(file_path, "rb") as f:
data = tomllib.load(f)
# Poetry dependencies
poetry_deps = data.get("tool", {}).get("poetry", {}).get("dependencies", {})
# PEP 621 dependencies
pep621_deps = data.get("project", {}).get("dependencies", {})
# Merge, remove python itself
deps = {}
if poetry_deps:
deps.update({k: v for k, v in poetry_deps.items() if k.lower() != "python"})
if pep621_deps:
# pep621_deps can be list like ["requests>=2.0"]
for item in pep621_deps:
name = item.split()[0].split(">=")[0].split("==")[0].split("<=")[0]
deps[name] = item
return list(deps.keys())


def get_requires_python(pkg_name):
try:
dist = next(d for d in distributions() if d.metadata["Name"].lower() == pkg_name.lower())
requires_python = dist.metadata.get("Requires-Python")
return requires_python or "Any"
except StopIteration:
return "Not installed / metadata missing"


def min_python_from_specifier(spec):
"""Return the lowest Python version that satisfies a specifier string."""
if spec in (None, "Any"):
return None
try:
spec_set = SpecifierSet(spec)
# Find the lowest version manually (best-effort)
for major in range(2, 4):
for minor in range(0, 20):
v = f"{major}.{minor}"
if parse_version(v) in spec_set:
return v
except Exception:
return None
return None


def main(pyproject_file="pyproject.toml"):
packages = read_pyproject(pyproject_file)
results = []

for pkg in packages:
req_python = get_requires_python(pkg)
min_py = min_python_from_specifier(req_python)
results.append((pkg, req_python, min_py))

print(f"{'Package':25} | {'Requires-Python':20} | {'Min Python'}")
print("-" * 70)
min_versions = []
for pkg, spec, min_py in results:
print(f"{pkg:25} | {spec:20} | {min_py or 'Unknown'}")
if min_py:
min_versions.append(parse_version(min_py))

if min_versions:
overall_min = str(min(min_versions))
print("\nOverall minimum Python version to satisfy all dependencies:", overall_min)
else:
print("\nCould not determine overall minimum Python version (check metadata).")


if __name__ == "__main__":
main()
499 changes: 499 additions & 0 deletions notebook/window_length.ipynb

Large diffs are not rendered by default.

80 changes: 31 additions & 49 deletions quicklook/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
from flask import Flask, render_template, request, jsonify
from flask_sock import Sock
from loguru import logger
from quicklook.tql import TessQuickLook, ALL_TESS_PIPELINES
from quicklook.tql import TessQuickLook
from quicklook.pipelines import ALL_TESS_PIPELINES, HLSP_PIPELINES
from quicklook.cli.ql import sanitize_target_name
from quicklook.exceptions import QuickLookError
from quicklook.utils import get_available_pipelines, get_available_sectors
Expand Down Expand Up @@ -83,48 +84,25 @@ def encoding(self):


# ---------------------------------------------------------------------------
# SQLite job history
# SQLite job history (schema + CRUD live in quicklook.app.jobs_db)
# ---------------------------------------------------------------------------
def _init_db():
conn = sqlite3.connect(str(DB_PATH))
conn.execute(
"""CREATE TABLE IF NOT EXISTS job_history (
name TEXT PRIMARY KEY,
status TEXT NOT NULL,
error TEXT DEFAULT '',
params TEXT DEFAULT '{}',
submitted_at REAL,
finished_at REAL,
step_times TEXT DEFAULT '{}'
)"""
)
conn.commit()
conn.close()

from quicklook.app.jobs_db import JobsDB # noqa: E402

_init_db()
_jobs_db = JobsDB(DB_PATH)


def _save_job_history(
name, status, error="", params=None, submitted_at=None, finished_at=None, step_times=None
):
conn = sqlite3.connect(str(DB_PATH))
conn.execute(
"INSERT OR REPLACE INTO job_history "
"(name, status, error, params, submitted_at, finished_at, step_times) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
name,
status,
error,
json.dumps(params or {}),
submitted_at,
finished_at,
json.dumps(step_times or {}),
),
_jobs_db.save(
name=name,
status=status,
error=error,
params=params,
submitted_at=submitted_at,
finished_at=finished_at,
step_times=step_times,
)
conn.commit()
conn.close()


_avg_step_cache = {"data": {}, "ts": 0}
Expand All @@ -137,18 +115,12 @@ def _get_avg_step_times():
with _avg_step_lock:
if now - _avg_step_cache["ts"] < 60:
return dict(_avg_step_cache["data"])
conn = sqlite3.connect(str(DB_PATH))
rows = conn.execute(
"SELECT step_times FROM job_history WHERE status='done' "
"ORDER BY finished_at DESC LIMIT 20"
).fetchall()
conn.close()
if not rows:
step_dicts = _jobs_db.recent_step_times(limit=20)
if not step_dicts:
return {}
totals: dict[str, float] = {}
counts: dict[str, int] = {}
for (st_json,) in rows:
st = json.loads(st_json) if st_json else {}
for st in step_dicts:
for step, dur in st.items():
totals[step] = totals.get(step, 0) + dur
counts[step] = counts.get(step, 0) + 1
Expand Down Expand Up @@ -185,12 +157,17 @@ def _job_worker():
_worker_thread = Thread(target=_job_worker, daemon=True)
_worker_thread.start()

# Label for the TGLC local ePSF-extraction step. Kept as a named constant
# because the websocket handler matches on it for fine-grained sub-progress.
EPSF_STEP_LABEL = "Extracting ePSF light curve"

# Pipeline step definitions for progress tracking.
PIPELINE_STEPS = [
(r"Generating quicklook", "Initializing"),
(r"Catalog names:|TIC \d+", "Resolving target"),
(r"Available sectors:|All available lightcurves", "Searching lightcurves"),
(r"Downloading|search_lightcurve|Using .+ TPF", "Downloading data"),
(r"ePSF fitting", EPSF_STEP_LABEL),
(r"Plotting raw light curve|raw lc", "Raw light curve"),
(r"flatten|biweight|cosine|Flattening", "Flattening light curve"),
(r"Lomb-Scargle|GLS|Generalized Lomb", "Lomb-Scargle periodogram"),
Expand Down Expand Up @@ -681,10 +658,7 @@ def delete_job(target):

# Drop the SQLite history row (best-effort).
try:
conn = sqlite3.connect(str(DB_PATH))
conn.execute("DELETE FROM job_history WHERE name = ?", (target,))
conn.commit()
conn.close()
_jobs_db.delete(target)
except sqlite3.DatabaseError as e:
logger.warning(f"Failed to delete job_history row for {target}: {e}")

Expand Down Expand Up @@ -813,6 +787,15 @@ def ws_log(ws, target):
step_label = PIPELINE_STEPS[step_idx][1] if step_idx >= 0 else "Starting"
pct = int(((step_idx + 1) / step_total) * 100) if step_idx >= 0 else 0

# Fine-grained sub-progress within the long TGLC ePSF step:
# interpolate the bar from the "ePSF fitting: N/M (X%)" log lines.
if step_idx >= 0 and PIPELINE_STEPS[step_idx][1] == EPSF_STEP_LABEL:
epsf_pcts = re.findall(r"ePSF fitting: \d+/\d+ \((\d+)%\)", full_log)
if epsf_pcts:
frac = int(epsf_pcts[-1]) / 100.0
pct = int(((step_idx + frac) / step_total) * 100)
step_label = f"{EPSF_STEP_LABEL} ({epsf_pcts[-1]}%)"

# Record step timing transitions
if step_idx > last_step_idx:
now = time.time()
Expand Down Expand Up @@ -935,7 +918,6 @@ def _parse_tls_filename(stem):
recoverable.
"""
SPOC_FLUX = {"pdcsap", "sap"}
HLSP_PIPELINES = {"qlp", "tglc", "tasoc", "cdips", "pathos", "tess-spoc", "t16"}
# Trailing "_tls" already stripped by Path.stem callers
if stem.endswith("_tls"):
stem = stem[: -len("_tls")]
Expand Down
114 changes: 114 additions & 0 deletions quicklook/app/jobs_db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Tiny persistence layer for the GUI job-history SQLite database.

One module owns the schema, the version pragma, and the CRUD it needs.
The app code talks to ``JobsDB`` instead of cracking open sqlite3
inline, so future schema changes ship from one place — bump
``SCHEMA_VERSION``, add a migration branch in ``_migrate``, and the
next process start handles it.
"""

from __future__ import annotations

import json
import sqlite3
from pathlib import Path
from typing import Any

SCHEMA_VERSION = 1


def _connect(path: Path) -> sqlite3.Connection:
return sqlite3.connect(str(path))


def _migrate(conn: sqlite3.Connection) -> None:
current = conn.execute("PRAGMA user_version").fetchone()[0]
if current == SCHEMA_VERSION:
return
if current == 0:
conn.execute(
"""CREATE TABLE IF NOT EXISTS job_history (
name TEXT PRIMARY KEY,
status TEXT NOT NULL,
error TEXT DEFAULT '',
params TEXT DEFAULT '{}',
submitted_at REAL,
finished_at REAL,
step_times TEXT DEFAULT '{}'
)"""
)
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
conn.commit()
return
# Future versions: add elif current == N branches that migrate
# forward to SCHEMA_VERSION. Never silently downgrade.
raise RuntimeError(
f"jobs.db user_version={current} is newer than SCHEMA_VERSION={SCHEMA_VERSION}; "
"refusing to run against an unknown future schema."
)


class JobsDB:
"""Thin wrapper around the job_history table. Stateless per call."""

def __init__(self, path: Path):
self.path = path
self.init()

def init(self) -> None:
conn = _connect(self.path)
try:
_migrate(conn)
finally:
conn.close()

def save(
self,
name: str,
status: str,
error: str = "",
params: dict[str, Any] | None = None,
submitted_at: float | None = None,
finished_at: float | None = None,
step_times: dict[str, float] | None = None,
) -> None:
conn = _connect(self.path)
try:
conn.execute(
"INSERT OR REPLACE INTO job_history "
"(name, status, error, params, submitted_at, finished_at, step_times) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
name,
status,
error,
json.dumps(params or {}),
submitted_at,
finished_at,
json.dumps(step_times or {}),
),
)
conn.commit()
finally:
conn.close()

def recent_step_times(self, limit: int = 20) -> list[dict[str, float]]:
"""Return parsed step_times dicts from the most recent successful jobs."""
conn = _connect(self.path)
try:
rows = conn.execute(
"SELECT step_times FROM job_history WHERE status='done' "
"ORDER BY finished_at DESC LIMIT ?",
(limit,),
).fetchall()
finally:
conn.close()
return [json.loads(st) if st else {} for (st,) in rows]

def delete(self, name: str) -> None:
conn = _connect(self.path)
try:
conn.execute("DELETE FROM job_history WHERE name = ?", (name,))
conn.commit()
finally:
conn.close()
Loading
Loading