noaa-goes-visualization/tests/test_db.py

286 lines
11 KiB
Python
Raw Permalink Normal View History

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()