286 lines
11 KiB
Python
286 lines
11 KiB
Python
|
|
import multiprocessing
|
||
|
|
import sqlite3
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from suvi import db, paths
|
||
|
|
|
||
|
|
|
||
|
|
def make_frame(conn, satellite=16, wavelength=171, minute=0):
|
||
|
|
t_start = 1715299200 + minute * paths.CADENCE
|
||
|
|
name = paths.FrameName(
|
||
|
|
satellite=satellite,
|
||
|
|
wavelength=wavelength,
|
||
|
|
t_start=t_start,
|
||
|
|
t_end=t_start + paths.CADENCE,
|
||
|
|
version="1-0-2",
|
||
|
|
)
|
||
|
|
return db.upsert_frame(conn, name, name.relpath(), size_bytes=1774080, mtime=1.0), name
|
||
|
|
|
||
|
|
|
||
|
|
def test_connect_creates_schema(db_path):
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
tables = {
|
||
|
|
row["name"]
|
||
|
|
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||
|
|
}
|
||
|
|
assert {"frame", "header", "detector_run", "detection", "remote_file", "meta"} <= tables
|
||
|
|
assert conn.execute(
|
||
|
|
"SELECT value FROM meta WHERE key='schema_version'"
|
||
|
|
).fetchone()["value"] == str(db.SCHEMA_VERSION)
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_init_schema_is_idempotent(db_path):
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
db.init_schema(conn)
|
||
|
|
db.init_schema(conn)
|
||
|
|
assert conn.execute("SELECT count(*) c FROM meta").fetchone()["c"] == 1
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_schema_version_mismatch_is_refused(db_path):
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
conn.execute("UPDATE meta SET value='99' WHERE key='schema_version'")
|
||
|
|
conn.commit()
|
||
|
|
with pytest.raises(RuntimeError, match="schema version"):
|
||
|
|
db.init_schema(conn)
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_readonly_connect_requires_an_existing_file(tmp_path):
|
||
|
|
with pytest.raises(FileNotFoundError):
|
||
|
|
db.connect(str(tmp_path / "absent.sqlite"), readonly=True)
|
||
|
|
|
||
|
|
|
||
|
|
def test_readonly_connect_cannot_write(db_path):
|
||
|
|
db.connect(db_path).close()
|
||
|
|
conn = db.connect(db_path, readonly=True)
|
||
|
|
with pytest.raises(sqlite3.OperationalError):
|
||
|
|
conn.execute("INSERT INTO meta (key, value) VALUES ('x', 'y')")
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_upsert_frame_is_stable_and_updates_in_place(db_path):
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
first, name = make_frame(conn)
|
||
|
|
again = db.upsert_frame(conn, name, name.relpath(), size_bytes=999, mtime=2.0)
|
||
|
|
assert first == again
|
||
|
|
row = conn.execute("SELECT * FROM frame WHERE id=?", (first,)).fetchone()
|
||
|
|
assert row["size_bytes"] == 999 and row["mtime"] == 2.0
|
||
|
|
assert conn.execute("SELECT count(*) c FROM frame").fetchone()["c"] == 1
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_duplicate_slot_under_a_new_version_is_rejected(db_path):
|
||
|
|
"""Two files claiming the same observation must not silently shadow each other."""
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
_, name = make_frame(conn)
|
||
|
|
other = paths.FrameName(
|
||
|
|
name.satellite, name.wavelength, name.t_start, name.t_end, version="1-0-3"
|
||
|
|
)
|
||
|
|
with pytest.raises(sqlite3.IntegrityError):
|
||
|
|
db.upsert_frame(conn, other, other.relpath())
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_frame_id_by_slot(db_path):
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
frame_id, name = make_frame(conn)
|
||
|
|
assert db.frame_id_by_slot(conn, *name.slot) == frame_id
|
||
|
|
assert db.frame_id_by_slot(conn, 18, 171, name.t_start) is None
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_detector_runs_are_ordered_by_recency(db_path):
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
first = db.create_detector_run(conn, "header_v1", {"threshold": 1})
|
||
|
|
second = db.create_detector_run(conn, "header_v1", {"threshold": 2})
|
||
|
|
assert db.latest_run_id(conn, "header_v1") == second
|
||
|
|
assert first != second
|
||
|
|
assert db.latest_run_id(conn, "nope") is None
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_record_detections_stores_scores_and_upserts(db_path):
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
frame_id, _ = make_frame(conn)
|
||
|
|
run = db.create_detector_run(conn, "header_v1", {})
|
||
|
|
db.record_detections(conn, run, [(frame_id, "bad", "eclipse", {"img_mean": 1e-4}, 12)])
|
||
|
|
db.record_detections(conn, run, [(frame_id, "good", None, {"img_mean": 0.3}, 15)])
|
||
|
|
conn.commit()
|
||
|
|
row = conn.execute("SELECT * FROM detection").fetchone()
|
||
|
|
assert row["verdict"] == "good"
|
||
|
|
assert row["reason"] is None
|
||
|
|
assert row["scores_json"] == '{"img_mean": 0.3}'
|
||
|
|
assert conn.execute("SELECT count(*) c FROM detection").fetchone()["c"] == 1
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_record_detections_rejects_unknown_verdicts(db_path):
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
frame_id, _ = make_frame(conn)
|
||
|
|
run = db.create_detector_run(conn, "header_v1", {})
|
||
|
|
with pytest.raises(ValueError, match="Unknown verdict"):
|
||
|
|
db.record_detections(conn, run, [(frame_id, "maybe", None, None, 0)])
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_detections_cascade_when_a_run_is_deleted(db_path):
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
frame_id, _ = make_frame(conn)
|
||
|
|
run = db.create_detector_run(conn, "header_v1", {})
|
||
|
|
db.record_detections(conn, run, [(frame_id, "good", None, None, 0)])
|
||
|
|
conn.execute("DELETE FROM detector_run WHERE id=?", (run,))
|
||
|
|
conn.commit()
|
||
|
|
assert conn.execute("SELECT count(*) c FROM detection").fetchone()["c"] == 0
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_good_slots_filters_by_verdict_window_and_band(db_path):
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
run = db.create_detector_run(conn, "header_v1", {})
|
||
|
|
ids = {}
|
||
|
|
for minute in range(4):
|
||
|
|
for wavelength in (171, 304):
|
||
|
|
frame_id, name = make_frame(conn, wavelength=wavelength, minute=minute)
|
||
|
|
ids[(minute, wavelength)] = (frame_id, name)
|
||
|
|
# Everything good except one frame, plus a decoy on the other satellite.
|
||
|
|
db.record_detections(
|
||
|
|
conn,
|
||
|
|
run,
|
||
|
|
[
|
||
|
|
(fid, "bad" if key == (1, 171) else "good", None, None, 0)
|
||
|
|
for key, (fid, _) in ids.items()
|
||
|
|
],
|
||
|
|
)
|
||
|
|
other, _ = make_frame(conn, satellite=18, minute=0)
|
||
|
|
db.record_detections(conn, run, [(other, "good", None, None, 0)])
|
||
|
|
conn.commit()
|
||
|
|
|
||
|
|
base = ids[(0, 171)][1].t_start
|
||
|
|
rows = db.good_slots(conn, run, base, base + 3 * paths.CADENCE, 16, (171,))
|
||
|
|
assert [r["t_start"] for r in rows] == [base, base + 2 * paths.CADENCE]
|
||
|
|
assert all(r["satellite"] == 16 and r["wavelength"] == 171 for r in rows)
|
||
|
|
|
||
|
|
both = db.good_slots(conn, run, base, base + 4 * paths.CADENCE, 16, (171, 304))
|
||
|
|
assert len(both) == 7 # 8 frames minus the one marked bad
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_unscanned_frames_lists_only_frames_without_headers(db_path):
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
first, _ = make_frame(conn, minute=0)
|
||
|
|
second, _ = make_frame(conn, minute=1)
|
||
|
|
db.record_headers(conn, [(first, {"img_mean": 0.3, "eclipse": 0}, None)])
|
||
|
|
conn.commit()
|
||
|
|
assert [r["id"] for r in db.unscanned_frames(conn)] == [second]
|
||
|
|
assert len(db.unscanned_frames(conn, limit=0)) == 0
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_record_headers_round_trips_values_and_errors(db_path):
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
first, _ = make_frame(conn, minute=0)
|
||
|
|
second, _ = make_frame(conn, minute=1)
|
||
|
|
db.record_headers(
|
||
|
|
conn,
|
||
|
|
[
|
||
|
|
(first, {"img_mean": 0.31, "degraded": 0, "datasum": "261950686"}, None),
|
||
|
|
(second, {}, "truncated: 17 bytes"),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
conn.commit()
|
||
|
|
good = conn.execute("SELECT * FROM header WHERE frame_id=?", (first,)).fetchone()
|
||
|
|
assert good["img_mean"] == pytest.approx(0.31)
|
||
|
|
assert good["read_ok"] == 1 and good["read_error"] is None
|
||
|
|
bad = conn.execute("SELECT * FROM header WHERE frame_id=?", (second,)).fetchone()
|
||
|
|
assert bad["read_ok"] == 0 and bad["read_error"].startswith("truncated")
|
||
|
|
assert bad["img_mean"] is None
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_record_headers_upserts(db_path):
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
frame_id, _ = make_frame(conn)
|
||
|
|
db.record_headers(conn, [(frame_id, {"img_mean": 0.1}, None)])
|
||
|
|
db.record_headers(conn, [(frame_id, {"img_mean": 0.9}, None)])
|
||
|
|
conn.commit()
|
||
|
|
assert conn.execute("SELECT count(*) c FROM header").fetchone()["c"] == 1
|
||
|
|
assert conn.execute("SELECT img_mean FROM header").fetchone()["img_mean"] == 0.9
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_remote_file_round_trip(db_path):
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
db.record_remote_files(conn, [("http://x/a.fits", 100.0, 17, "a.fits", 5.0)])
|
||
|
|
db.record_remote_files(conn, [("http://x/a.fits", 200.0, 18, "a.fits", 6.0)])
|
||
|
|
conn.commit()
|
||
|
|
assert db.get_remote_mtime(conn, "http://x/a.fits") == 200.0
|
||
|
|
assert db.get_remote_mtime(conn, "http://x/missing.fits") is None
|
||
|
|
assert conn.execute("SELECT count(*) c FROM remote_file").fetchone()["c"] == 1
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_writer_loop_drains_a_queue_from_another_process(db_path):
|
||
|
|
"""Workers stay read-only; one writer owns the lock. This is that contract."""
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
frame_ids = [make_frame(conn, minute=i)[0] for i in range(20)]
|
||
|
|
run = db.create_detector_run(conn, "header_v1", {})
|
||
|
|
conn.commit()
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
ctx = multiprocessing.get_context("spawn")
|
||
|
|
queue = ctx.Queue()
|
||
|
|
writer = ctx.Process(target=db.writer_loop, args=(db_path, queue), daemon=True)
|
||
|
|
writer.start()
|
||
|
|
for frame_id in frame_ids:
|
||
|
|
queue.put(("detection", (run, frame_id, "good", None, {"s": 1}, 3)))
|
||
|
|
queue.put(("header", (frame_ids[0], {"img_mean": 0.5}, None)))
|
||
|
|
queue.put(("remote_file", ("http://x/a.fits", 1.0, 2, "a", 3.0)))
|
||
|
|
queue.put(db.STOP)
|
||
|
|
writer.join(timeout=60)
|
||
|
|
assert writer.exitcode == 0
|
||
|
|
|
||
|
|
conn = db.connect(db_path, readonly=True)
|
||
|
|
assert conn.execute("SELECT count(*) c FROM detection").fetchone()["c"] == 20
|
||
|
|
assert conn.execute("SELECT count(*) c FROM header").fetchone()["c"] == 1
|
||
|
|
assert conn.execute("SELECT count(*) c FROM remote_file").fetchone()["c"] == 1
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_writer_loop_rejects_unknown_message_kinds(db_path):
|
||
|
|
ctx = multiprocessing.get_context("spawn")
|
||
|
|
queue = ctx.Queue()
|
||
|
|
writer = ctx.Process(target=db.writer_loop, args=(db_path, queue), daemon=True)
|
||
|
|
writer.start()
|
||
|
|
queue.put(("bogus", ()))
|
||
|
|
writer.join(timeout=60)
|
||
|
|
assert writer.exitcode not in (0, None)
|
||
|
|
|
||
|
|
|
||
|
|
def test_latest_run_is_scoped_by_config(db_path):
|
||
|
|
"""Runs accumulate one per detector per case, so 'latest' is ambiguous.
|
||
|
|
|
||
|
|
Resolving a detector without its config picks whichever case ran most
|
||
|
|
recently -- and scoring one case's verdicts against another case's injections
|
||
|
|
gives numbers that are wrong, not merely noisy.
|
||
|
|
"""
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
first = db.create_detector_run(conn, "header_v1", {"case": "alpha"})
|
||
|
|
second = db.create_detector_run(conn, "header_v1", {"case": "beta"})
|
||
|
|
|
||
|
|
assert db.latest_run_id(conn, "header_v1") == second # global latest
|
||
|
|
assert db.latest_run_id(conn, "header_v1", {"case": "alpha"}) == first
|
||
|
|
assert db.latest_run_id(conn, "header_v1", {"case": "beta"}) == second
|
||
|
|
assert db.latest_run_id(conn, "header_v1", {"case": "gamma"}) is None
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_scoped_lookup_takes_the_newest_matching_run(db_path):
|
||
|
|
conn = db.connect(db_path)
|
||
|
|
db.create_detector_run(conn, "disc_v1", {"case": "alpha"})
|
||
|
|
newer = db.create_detector_run(conn, "disc_v1", {"case": "alpha"})
|
||
|
|
assert db.latest_run_id(conn, "disc_v1", {"case": "alpha"}) == newer
|
||
|
|
conn.close()
|