diff --git a/CHANGELOG.md b/CHANGELOG.md index e486bae..402dd9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,13 +24,16 @@ window). `SeismoDb()` construction, no manual migration). `query_events` / `get_event` (and thus `/db/events`) return it automatically (`SELECT *`). - **Mutual exclusivity with `false_trigger`** — setting `reviewed_real=1` - clears `false_trigger`, and vice versa, both via `set_false_trigger` / - the sidecar review PATCH path. + clears `false_trigger`, and vice versa, enforced on both review paths: + the sidecar review PATCH (`update_event_review`) and the quick + `PATCH /db/events/{id}/false_trigger` endpoint (`set_false_trigger`). - **`find_twins`** — matches an event's histogram/waveform twins by serial + identical peak-vector-sum + a timestamp window. - **`propagate_review_to_twins`** — copies an event's `false_trigger`/ - `reviewed_real` state onto its twins, wired into the - `PATCH /db/events/{id}/sidecar` review path so flagging one flags both. + `reviewed_real` state onto its twins, wired into both the + `PATCH /db/events/{id}/sidecar` review path and the quick + `PATCH /db/events/{id}/false_trigger` path, so flagging one flags both + regardless of which endpoint made the change. --- diff --git a/sfm/database.py b/sfm/database.py index c0e8654..6986208 100644 --- a/sfm/database.py +++ b/sfm/database.py @@ -610,6 +610,16 @@ class SeismoDb: within ``window_seconds`` of this event's timestamp. Excludes the event itself. Returns [] if the event or any required field (serial / peak_vector_sum / timestamp) is missing. + + Caveat: identical-PVS matching is a proxy for "same physical event + recorded twice," not a guarantee. In the rare case where the device + clamps/saturates PVS (clamped to sqrt(3) * geo_range), two distinct + saturated events on the same serial within the window can share the + same clamped PVS value and be matched as twins even though they are + different events. This is harmless in practice — false_trigger/ + reviewed_real are derived/index columns re-derivable from the + sidecar source of truth — but worth knowing if twin counts look + surprising on a saturated/clamped run. """ row = self.get_event(event_id) if not row: @@ -652,12 +662,25 @@ class SeismoDb: return moved def set_false_trigger(self, event_id: str, value: bool) -> bool: - """Set or clear the false_trigger flag on an event. Returns True if found.""" + """ + Set or clear the false_trigger flag on an event. Returns True if found. + + Mirrors the 3-state exclusivity enforced by `update_event_review`: + setting false_trigger true also clears `reviewed_real` (a false + trigger can't also be a confirmed-real event). Clearing false_trigger + leaves `reviewed_real` untouched. + """ with self._connect() as conn: - cur = conn.execute( - "UPDATE events SET false_trigger=? WHERE id=?", - (1 if value else 0, event_id), - ) + if value: + cur = conn.execute( + "UPDATE events SET false_trigger=1, reviewed_real=0 WHERE id=?", + (event_id,), + ) + else: + cur = conn.execute( + "UPDATE events SET false_trigger=0 WHERE id=?", + (event_id,), + ) return cur.rowcount > 0 def delete_event(self, event_id: str) -> Optional[dict]: diff --git a/sfm/server.py b/sfm/server.py index c658196..eabc45a 100644 --- a/sfm/server.py +++ b/sfm/server.py @@ -1957,7 +1957,10 @@ def db_set_false_trigger( value: bool = Query(..., description="True to flag as false trigger, False to clear"), ) -> dict: """ - Set or clear the false_trigger flag on a single event. + Set or clear the false_trigger flag on a single event. Enforces the + same 3-state exclusivity as the sidecar review PATCH (flagging false_trigger + clears reviewed_real) and propagates the resulting flags to the event's + histogram/waveform twin(s), same as the sidecar path. Used by the terra-view event review UI. Returns 404 if the event_id is not found. @@ -1965,6 +1968,12 @@ def db_set_false_trigger( found = _get_db().set_false_trigger(event_id, value) if not found: raise HTTPException(status_code=404, detail=f"Event {event_id} not found") + + try: + _get_db().propagate_review_to_twins(event_id) + except Exception as exc: + log.warning("twin review-propagation failed for %s: %s", event_id, exc) + return {"status": "ok", "event_id": event_id, "false_trigger": value} diff --git a/tests/test_set_false_trigger_exclusivity.py b/tests/test_set_false_trigger_exclusivity.py new file mode 100644 index 0000000..e72f865 --- /dev/null +++ b/tests/test_set_false_trigger_exclusivity.py @@ -0,0 +1,29 @@ +from sfm.database import SeismoDb +from minimateplus.models import Event + + +def _ins(db, eid_key="01110000", serial="BE1"): + ev = Event(index=0); ev._waveform_key = bytes.fromhex(eid_key) + db.insert_events([ev], serial=serial) + return db.query_events(serial=serial)[0]["id"] + + +def test_set_false_trigger_clears_reviewed_real(tmp_path): + db = SeismoDb(tmp_path / "s.db"); eid = _ins(db) + db.update_event_review(eid, {"reviewed_real": True}) + assert db.get_event(eid)["reviewed_real"] == 1 + + db.set_false_trigger(eid, True) + row = db.get_event(eid) + assert row["false_trigger"] == 1 and row["reviewed_real"] == 0 + + +def test_clearing_false_trigger_leaves_reviewed_real_untouched(tmp_path): + db = SeismoDb(tmp_path / "s.db"); eid = _ins(db) + db.update_event_review(eid, {"false_trigger": True}) + db.set_false_trigger(eid, False) + row = db.get_event(eid) + assert row["false_trigger"] == 0 + # reviewed_real was never set true, so it stays 0 either way; the point + # is set_false_trigger(False) does not touch the column at all. + assert row["reviewed_real"] == 0