muscat-db is a web-based, closed-loop exoplanet observing and analysis workflow
that integrates a central database with semi-automated, reproducible photometry,
transit, ephemeris, and TTV analyses, enabling efficient LCO scheduling with
optional FOV and exposure-time optimization and integration with external TOI
and NExScI catalogs. Supported workflow pages expose a unique, shareable view state,
allowing configurations, selections, and results to be reviewed transparently,
reproduced consistently, and communicated easily among collaborators.
muscat-db orchestrates three external packages. Each package's code lives in its
own ext_tools/ checkout and is installed editable into a dedicated conda
environment, which supplies its dependencies rather than the code itself — so the
checkout's git ref, not the environment, determines what actually runs. muscat-db
stores their inputs, launches them as background jobs, and renders their outputs;
the science lives in the packages themselves.
| Stage | Engine | Sampler | Outputs |
|---|---|---|---|
| Photometry | prose2 |
— | Lightcurve CSVs, diagnostics |
| Transit fitting | timer |
PyMC (NUTS) | Posteriors, corner/trace/fit plots |
| TTV fitting | harmonic |
emcee | Posteriors, corner/trace/fit plots |
- Python ≥ 3.12
- FITS files below the common data root (
MUSCAT_DATA_DIR, default/data) inMuSCAT,MuSCAT2,MuSCAT3,MuSCAT4,Sinistro,SBIGSTL6303, andQHY600CMOSsubdirectories - A writable obslog directory (
MUSCAT_OBSLOG_DIR, default$HOME/muscat/obslog)
| Instrument | CCDs | FITS prefix | Data dir |
|---|---|---|---|
| muscat | 3 | MSCT |
/data/MuSCAT |
| muscat2 | 4 | MCT2 |
/data/MuSCAT2 |
| muscat3 | 4 | ogg2m001- |
/data/MuSCAT3 |
| muscat4 | 4 | coj2m002- |
/data/MuSCAT4 |
| sinistro | 1 | * (any LCO 1m site) |
/data/Sinistro |
| sbig | 1 | * (any LCO 0.4m site) |
/data/SBIGSTL6303 |
| qhy600 | 1 | * (any LCO 0.4m site) |
/data/QHY600CMOS |
Sinistro/sbig/qhy600 scan the reduced *e91.fits frames produced by LCO BANZAI, regardless of site prefix (elp1m008-, coj1m003-, cpt1m013-, ogg0m406-, coj0m416-, …). sbig (SBIG STL-6303, INSTRUME prefix kb) is archival-only -- LCO's live instrument API has no schedulable instrument_type for it. qhy600 (QHY600 CMOS on DeltaRho 350, INSTRUME prefix sq) is the current live 0.4m camera and is schedulable; both prefixes and the full header convention (WCS, GAIN=1, CONFMODE) are confirmed against real archived frames.
The exposure calculator uses these instrument references when scaling its MuSCAT3 calibration. Full well is in electrons, gain in electrons/ADU, pixel scale in arcsec/pixel, and aperture in metres.
| Instrument | Full well | Gain | Pixel scale | Aperture |
|---|---|---|---|---|
| muscat | 55,000 | 1.0 | 0.358 | 1.88 |
| muscat2 | 62,000 | 1.0 | 0.44 | 1.52 |
| muscat3 | 99,000 | 1.8 | 0.267 | 2.0 |
| muscat4 | 99,000 | 1.8 | 0.267 | 2.0 |
| sinistro | 100,000 | 1.5 | 0.39 | 1.0 |
| sbig | 102,400 | 1.0 | 0.58 | 0.4 |
| qhy600 | 47,400 | 1.0 | 0.74 | 0.35 |
Choose either workflow below. Commands elsewhere in this README use the
installed muscat-db executable; when using uv, prefix those commands with
uv run.
uv (recommended for repository development)
Install the locked project environment from the repository root:
uv syncRun the CLI or start the web interface inside that environment:
uv run muscat-db --help
uv run muscat-db servepip
Create and activate a virtual environment, then install the project in editable mode:
python -m venv .venv
source .venv/bin/activate
python -m pip install -e .Run the installed CLI or start the web interface:
muscat-db --help
muscat-db serveRuntime configuration is read from environment variables. Core paths and safe
operational settings have in-code defaults, while optional integrations (for
example live LCO access, ADS search, and nova astrometry) require credentials.
The central registry in src/muscat_db/config.py drives the startup status
report, and .env.example documents the common deployment settings.
On import muscat-db auto-loads a .env file (via python-dotenv,
find_dotenv searching upward from the working directory). A .env is
optional. When absent, load_dotenv is a no-op and the defaults apply.
Copy the template only when you want to override a default or pin a value:
cp .env.example .env # then editMUSCAT_DATA_DIR is the common raw-data root, not one instrument's directory.
Each instrument resolves below it using its canonical case-sensitive directory
name, for example $MUSCAT_DATA_DIR/MuSCAT3/<yymmdd>/.
The /toi and /nexsci pages' "has HARPS RV" / "has LAMOST RV" filters
coordinate-match against two catalogs:
data/HARPS_RVBank_targets.csv(ordata/HARPS_RVBank_ver02.csv[.zip]as a fallback) — override the path withMUSCAT_HARPS_TARGETS_CSV/MUSCAT_HARPS_RVBANK_CSV/MUSCAT_HARPS_RVBANK_ZIP.data/LAMA_stars.csv— override withMUSCAT_LAMOST_STARS_CSV.
Neither is fetched by scripts/download_catalogs.sh, both are gitignored, and
there is no online fallback for this coordinate-match path (unlike the target
page's live HARPS lookup) — without them, _harps_coord_membership/
_lamost_coord_membership in catalog.py silently return zero matches (no
error), so the header counts read 0 and the filter chips return nothing. A
deploy checkout brought up via git reset --hard never carries gitignored
files, so it starts out missing these two.
Point the three env vars above at a checkout that already has the files
(e.g. the dev checkout) instead of copying them into every deploy checkout —
same reasoning as MUSCAT_DB_PATH pointing production at a shared DB in
Configuration. A copy works too, but silently drifts stale
with no re-sync mechanism, which is how this got missed in the first place.
The production database is rebuilt once a day from a crontab entry on the
deployment host (not checked in, since it's host-specific — the account,
MUSCATDB_ROOT, and MUSCAT_OBSLOG_DIR all vary by host):
MUSCAT_OBSLOG_DIR=/ut2/muscat/obslog
MUSCATDB_ROOT=/path/to/muscat-db
30 17 * * * cd $MUSCATDB_ROOT && bash scripts/download_catalogs.sh >> $MUSCATDB_ROOT/logs/download_catalogs.log 2>&1 && uv run muscat-db scan-yesterday >> $MUSCATDB_ROOT/logs/scan.log 2>&1 && uv run muscat-db build-db >> $MUSCATDB_ROOT/logs/build-db.log 2>&1
MUSCAT_OBSLOG_DIR is exported inline rather than left to .env because cron
runs with a minimal environment; pin it in .env too for manual/GUI runs (see
Configuration). scan-yesterday only scans the previous
day's obslog, so a one-off manual build-db or scan never gets silently
overwritten by the next nightly run of this job.
build-db snapshots the existing database before rebuilding it (SQLite backup
API, safe while the server is running) to $MUSCAT_DB_BACKUP_DIR (default
$MUSCAT_TMPDIR, i.e. ~/temp) as muscat.db.nightly-<stamp>.sqlite, keeping
the newest $MUSCAT_DB_BACKUP_KEEP (default 2). It aborts, leaving the live
file untouched, if either that snapshot or the freshly built database fails
PRAGMA integrity_check; a failed snapshot is kept with a .CORRUPT suffix.
The integrity checks add roughly 3-4 minutes to the nightly run.
The published docs site is https://muscat-team.github.io/muscatdb/ (updates when
main is released). The UI tour is https://muscat-team.github.io/muscatdb/home/.
On a running instance, the operator Guide is /guide. FastAPI Swagger is /docs.
To preview the tour locally after building docs:
uv run mkdocs build --site-dir site
uv run muscat-db build-static-site --out site/home --db mock_muscat.db --no-figuresUse a real muscat.db on the host instead of the mock database when you want
real figures. Rebuild the snapshot after MkDocs; mkdocs build deletes site/.
See CONTRIBUTING.md for dev setup, the pre-PR checklist, and the branch/PR workflow.
obsdate is not the observing night — it is the UTC calendar date, inherited
from the directory the frames sit in. A single continuous night whose frames
straddle 00:00 UTC is therefore recorded as two datasets, and appears as two rows
on the target page.
Verified example (TIC 460950389.01 / TOI 6715.01, Cerro Tololo, UTC−4):
240417 sinistro i 1.26–1.26 1 frame UT 23:59:57
240418 sinistro i 1.22–1.37 174 frames UT 00:01:28 – 04:19:28
Local time 19:59 → 00:19: one continuous ~4.3 h run, cut by a UTC boundary that has nothing to do with the night.
The LCO archive already stamps DAY-OBS into every filename, and it is correct:
all 175 frames above are named lsc1m004-fa03-20240417-NNNN-e91, sequence
0092→0267 contiguous. lco.py frame_destination() ignores that token and
derives the directory from DATE_OBS (the actual UTC timestamp) instead, so the
post-midnight frames land in 240418. scanner.py _find_fits_files() then
globs <data_dir>/<obsdate>/, and the directory name becomes the DB grouping key
verbatim.
The correct directory for any LCO frame is therefore readable from its own filename — no timezone or longitude arithmetic is required to detect or repair a split.
LCO-fed instruments only: sinistro, muscat3, muscat4. muscat and muscat2 are
not affected. MuSCAT2's telescope naming is already observing-night based —
1,079 of its 1,247 date directories contain post-midnight UT frames (e.g. dir
200113 runs through UT 00:59:45), so its nights are never cut.
Beware of surveying this with MIN/MAX on ut_start: it is a time-of-day
string, so MAX(ut_start) returns the largest clock reading (23:5x) rather
than the chronologically last frame. That flags every pair of consecutive nights
that each span midnight and produces large false-positive counts. Compare
jd_start instead, or compare each file's DAY-OBS token to its directory:
-- authoritative: a file whose parent directory disagrees with its own DAY-OBS
SELECT instrument, obsdate, substr(filename, 15, 8) AS dayobs, COUNT(*)
FROM frames WHERE instrument IN ('sinistro','muscat3','muscat4')
GROUP BY instrument, obsdate, dayobs;
-- a directory yielding >1 distinct dayobs is a splitAs of the 2026-07 survey, that query gives three distinct populations:
| Population | Count | Meaning |
|---|---|---|
| Mixed dirs, adjacent day | 16 dirs | genuine midnight splits (12 sinistro, 3 muscat3, 1 muscat4) |
| Mixed dirs, non-adjacent | 4 dirs | stray/misfiled data, unrelated to midnight (e.g. sinistro/220309 holds 181 frames dated 221209) |
| Pure but offset | 59 dirs, 42,674 files | whole directory labelled with the UTC date rather than DAY-OBS; internally consistent, not split |
Only the first population causes the two-row symptom. See Repairing a split below.
Two other parts of the stack already use an observation-night date, which is why this reads as an internal inconsistency rather than a missing feature:
lco.py_lco_observing_date()does site-timezone conversion plus roll-back-before-local-noon — but it only feeds scheduler dataset matching, never the download destination.- The photometry pipeline embeds an obs-night date in output filenames.
photometry.pydiscovered_targets()andlist_outputs()both document that this "may differ from the directory name (obs-night vs UT date)" and build regexes accepting any 6-digit date token to paper over the mismatch.
1. Use the filename's DAY-OBS token, not local-evening date.
_lco_observing_date() is the obvious function to reach for and is the wrong
one: it returns the local evening date, which for western sites differs from
the current directory name for the whole night, not just the post-midnight
part. Adopting it would relabel every elp (26,333 frames) and ogg (115) sinistro
dataset — plus all of muscat3 — by one day.
The correct convention is the UTC date at the start of the observing night, which is exactly what LCO already writes into each filename. Reading the token merges the split nights, leaves every currently-unsplit label byte-identical, and requires no site table.
2. A DB-layer night key alone does not make the merged night reducible.
photometry.py passes a single --data_dir to run_photometry, and prose
derives both the instrument and the obslog path from that directory's name. A
night spanning two obsdate directories cannot be reduced in one run by
regrouping in SQL alone — that fixes the browsing view while leaving photometry
unable to act on it. A fix has to either consolidate the frames on disk or teach
the pipeline to accept multiple input directories; the tool below takes the
disk-consolidation path.
build-db executes DROP TABLE IF EXISTS frames; summaries; targets and rebuilds
them entirely from the obslog CSV tree. App-owned tables (notes, overrides, jobs,
saved ephemeris views) are preserved; the observation-derived ones are not. Any
edit made directly to muscat.db is erased by the next nightly cron run.
The source-of-truth chain, top to bottom:
raw FITS $MUSCAT_DATA_DIR/<InstDir>/<yymmdd>/*.fits <- consolidate HERE
| scan_date() -> obslog-<inst>-<yymmdd>-ccd<N>.csv, opened mode "w"
obslog CSV $MUSCAT_OBSLOG_DIR/<inst>/<yymmdd>/
| _discover_csv_jobs() -> build-db
muscat.db frames / summaries / targets (fully derived - never edit)
Rule: a file belongs in the directory matching the 8-digit DAY-OBS token in its
own filename. This is implemented by muscat_db/obsdate_normalize.py and driven
by:
muscat-db normalize-obsdates # dry run over every LCO-fed instrument
muscat-db normalize-obsdates sinistro # dry run, one instrument
muscat-db normalize-obsdates --apply # perform it, then rescan + re-ingest
muscat-db normalize-obsdates --apply --no-rescanIt is a dry run unless --apply is passed, and it reports rather than moves
anything it cannot decide safely. Each run performs the moves, deletes the obslog
CSVs they invalidate, removes directories left empty, then rescans and re-ingests
every affected date. lco.py frame_destination() now derives the download
directory from the same filename token, so new downloads do not re-create splits.
Clearing the invalidated CSVs is the step that silently undoes everything if
skipped. scan_date writes CSVs only for CCDs that produced rows, and only
removes a stale one on its own in two narrower cases: a multi-CCD instrument
whose sibling CCD proves the date directory is genuinely readable this same
call (#108), or a single-CCD instrument (sinistro/sbig/qhy600) whose CSV has
gone unconfirmed for longer than MUSCAT_SCAN_STALE_CSV_GRACE_S (72h default,
#115) — and only when scanning the canonical MUSCAT_DATA_DIR root. Move
every frame of a CCD out of a directory and its old CSV can survive well past
that (indefinitely, if nothing ever rescans that exact date again),
_discover_csv_jobs still finds it, and build-db re-ingests the pre-move
split — the frames moved but the database looks untouched. normalize-obsdates --apply's own unconditional clearing (below) is the reliable, immediate path;
don't rely on either passive mechanism above to catch a consolidation.
The normalizer only considers files the instrument's own scanner would ingest,
reusing scanner._find_fits_files rather than re-deriving the glob. This matters:
the raw tree contains cross-filed data — /data/MuSCAT3/231007 holds 1,900
coj2m002- (MuSCAT4) frames that no instrument scans — and relocating those would
churn files without changing anything the database sees, while still leaving them
in the wrong instrument tree.
What can erase a consolidation:
| Eraser | Mechanism | Prevention |
|---|---|---|
build-db |
drops and rebuilds from CSVs | consolidate in the FITS tree, never in muscat.db |
| stale obslog CSV | usually survives a rescan (removed only by #108's same-call multi-CCD proof, or a single-CCD instrument's 72h grace window, #115); still ingested until then | delete it, or rename its dir to a non-YYMMDD name |
scan-missing / scan-all |
regenerates any raw dir whose name is not an existing obslog dir | keep raw and obslog dir names consistent; remove emptied raw dirs |
scan-yesterday (cron) |
scans yesterday only | historical consolidation is never revisited — this is why a one-off move survives |
| LCO re-download | frame_destination() uses DATE_OBS |
fix it, or every consolidation is temporary |
Escape hatch: _discover_csv_jobs skips directories failing _is_obsdate(), so
renaming 240418 to 240418_presplit keeps it on disk for provenance while
excluding it from build-db. This is an existing intentional pattern — the code
comment names csv_old_220914 and Hyades as precedents.
Because the rule is filename-driven the repair is idempotent, so it can be
re-asserted rather than performed once. Running normalize-obsdates --apply
before scan-yesterday in cron corrects any split reintroduced by a re-download
or a manual copy on the next pass.
Three classes are reported and left alone, because each needs a decision the tool cannot make:
| Reason | Meaning | Why it is not automated |
|---|---|---|
offset |
every frame in the directory carries the same DAY-OBS, and no directory of that name exists | moving them is a bare directory rename, changing a dataset label that photometry output paths, target_notes primary keys (object, obsdate, instrument) and job records already reference |
stray |
a frame whose DAY-OBS is more than a day from its directory | misfiled data, not a midnight artifact — e.g. sinistro/220309 holds frames dated 221209, nine months off |
conflict |
the destination already holds a file of that name | the same frame exists in both directories; deleting a copy is a data-removal decision, so the tool never does it implicitly |
Conflicts are duplicate copies of the same frame, not competing reductions.
They are not a .fits / .fits.fz pairing either: the scanner glob is
*e91.fits, so a conflict only fires when the identical unpacked basename exists
in both directories.
A 450-pair sample (150 per instrument) found every pair carrying the same
DATASUM, PIPEVER, DATE-OBS, DAY-OBS, OBJECT, EXPTIME and ORIGNAME —
the pixel data is byte-identical. The files nonetheless differ under md5sum
because the FITS CHECKSUM card is recomputed on write; in the sampled pairs the
difference was confined to that single card, or the files were byte-identical
outright. Comparing file size or md5sum is therefore misleading here — compare
DATASUM:
python -c "
from astropy.io import fits
a,b='<dir-A>/<frame>.fits','<dir-B>/<frame>.fits'
print(fits.getheader(a).get('DATASUM'), fits.getheader(b).get('DATASUM'))"Because the copies are redundant, the resolution is to delete the one in the wrong directory and keep the one already under its DAY-OBS. That is left manual: the normalizer relocates frames but never deletes science data.
A directory holding more than one DAY-OBS is always repaired: two nights sharing one directory is unambiguously wrong, so the minority moves even when the destination has to be created. A directory whose frames all agree with each other is only repaired when that night already has a home elsewhere.
As of the 2026-07 dry run: 10,328 frames would move (sinistro 7,427 across 17
directory pairs, muscat3 2,861 across 3, muscat4 40 across 1), with 28 offset,
6 stray and 7,083 conflict reports left for review.