fix(scada): verify first sample after field completion
This commit is contained in:
@@ -27,11 +27,11 @@ def evaluate(rows):
|
||||
incident_at=INCIDENT, interval_seconds=900)
|
||||
|
||||
|
||||
def test_recovery_uses_post_receipt_samples_and_pre_incident_same_phase_reference():
|
||||
def test_recovery_uses_nearest_post_receipt_sample_and_pre_incident_reference():
|
||||
result = evaluate(observations())
|
||||
assert result["status"] == "passed"
|
||||
assert [item["at"] for item in result["samples"]] == [
|
||||
(RETURNED + timedelta(minutes=30)).isoformat(), (RETURNED + timedelta(minutes=45)).isoformat()]
|
||||
assert result["required_samples"] == 1
|
||||
assert [item["at"] for item in result["samples"]] == [(RETURNED + timedelta(minutes=15)).isoformat()]
|
||||
assert all(check["normal_day_count"] == 15 for sample in result["samples"] for check in sample["devices"])
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ def test_samples_at_or_before_return_time_cannot_close_incident():
|
||||
assert evaluate(rows)["status"] == "waiting"
|
||||
|
||||
|
||||
def test_faster_sampling_counts_monitoring_periods_and_rejects_later_relapse():
|
||||
def test_nearest_sample_does_not_wait_for_monitoring_interval_or_use_later_data():
|
||||
rows = []
|
||||
for minute in range(5, 36, 5):
|
||||
at = RETURNED + timedelta(minutes=minute)
|
||||
@@ -67,9 +67,34 @@ def test_faster_sampling_counts_monitoring_periods_and_rejects_later_relapse():
|
||||
rows.extend({"time": at - timedelta(days=day), "device_id": device, "monitored_value": 100.}
|
||||
for day in range(1, 16))
|
||||
rows.append({"time": at, "device_id": device, "monitored_value": 100.})
|
||||
assert evaluate(rows)["status"] == "passed"
|
||||
first = evaluate(rows)
|
||||
assert first["status"] == "passed"
|
||||
assert first["verified_through"] == (RETURNED + timedelta(minutes=5)).isoformat()
|
||||
next(row for row in rows if row["time"] == RETURNED + timedelta(minutes=25) and row["device_id"] == "P1")["monitored_value"] = 50.
|
||||
assert evaluate(rows)["status"] == "waiting"
|
||||
assert evaluate(rows) == first
|
||||
|
||||
|
||||
@pytest.mark.parametrize("problem", ["abnormal", "invalid", "null", "missing_device"])
|
||||
def test_nearest_bad_sample_is_not_replaced_by_later_normal_samples(problem):
|
||||
rows = observations()
|
||||
nearest = RETURNED + timedelta(minutes=15)
|
||||
row = next(row for row in rows if row["time"] == nearest and row["device_id"] == "P1")
|
||||
if problem == "missing_device":
|
||||
rows.remove(row)
|
||||
elif problem == "invalid":
|
||||
row["quality"] = "invalid"
|
||||
else:
|
||||
row["monitored_value"] = 50. if problem == "abnormal" else None
|
||||
result = evaluate(rows)
|
||||
assert result["status"] == "waiting"
|
||||
assert result["sample"]["at"] == nearest.isoformat()
|
||||
|
||||
|
||||
def test_off_grid_feedback_uses_first_later_sample_even_without_monitoring_interval():
|
||||
result = evaluate_recovery(observations(), device_ids=["P1", "F1"],
|
||||
returned_at=RETURNED + timedelta(minutes=7), incident_at=INCIDENT, interval_seconds=0)
|
||||
assert result["status"] == "passed"
|
||||
assert result["verified_through"] == (RETURNED + timedelta(minutes=15)).isoformat()
|
||||
|
||||
|
||||
def test_dispatch_waits_for_all_orders_and_accepts_corrected_field_completion_time():
|
||||
|
||||
@@ -1128,8 +1128,8 @@ async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(
|
||||
at = returned + timedelta(minutes=15 * sample)
|
||||
rows.extend({"time": at - timedelta(days=day), "device_id": "P1", "monitored_value": 100.} for day in range(1, 16))
|
||||
rows.append({"time": at, "device_id": "P1", "monitored_value": 100.})
|
||||
reader = AsyncMock(return_value=rows[:-1])
|
||||
monkeypatch.setattr("app.infra.db.timescaledb.repositories.scada.ScadaRepository.get_scada_by_ids_time_range", reader)
|
||||
reader = AsyncMock(return_value=[row for row in rows if row["time"] < incident])
|
||||
monkeypatch.setattr("app.infra.db.timescaledb.repositories.scada.ScadaRepository.get_recovery_scada_readings", reader)
|
||||
assert await reconcile_recoveries(service, timescale, settings) == 0
|
||||
assert repository.task["handling_context"]["phase"] == "waiting_scada"
|
||||
reader.return_value = rows
|
||||
@@ -1138,23 +1138,24 @@ async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(
|
||||
recovery = repository.task["handling_context"]
|
||||
evidence_id = recovery["verification_evidence_id"]
|
||||
assert repository.evidence[evidence_id]["data_snapshot"]["data"]["status"] == "passed"
|
||||
assert repository.evidence[evidence_id]["data_snapshot"]["data"]["verified_through"] == (returned + timedelta(minutes=15)).isoformat()
|
||||
service._resolve_investigation_alerts.assert_not_awaited()
|
||||
|
||||
# Correcting the appointment's field return time invalidates the old proof
|
||||
# and requires two actual observations after the new business time.
|
||||
# and selects the nearest actual observation after the new business time.
|
||||
corrected_time = returned + timedelta(minutes=15)
|
||||
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=f"peripheral_sample:{child_id}", source_revision=3,
|
||||
status="completed", occurred_at=corrected_time, detail="修订现场实际完工时间"))
|
||||
assert repository.task["handling_context"]["phase"] == "waiting_scada"
|
||||
assert "verification_evidence_id" not in repository.task["handling_context"]
|
||||
reader.return_value = [row for row in rows if row["time"] <= corrected_time]
|
||||
assert await reconcile_recoveries(service, timescale, settings) == 0
|
||||
at = returned + timedelta(minutes=45)
|
||||
rows.extend({"time": at - timedelta(days=day), "device_id": "P1", "monitored_value": 100.} for day in range(1, 16))
|
||||
rows.append({"time": at, "device_id": "P1", "monitored_value": 100.})
|
||||
reader.return_value = rows
|
||||
assert await reconcile_recoveries(service, timescale, settings) == 1
|
||||
revised_id = repository.task["handling_context"]["verification_evidence_id"]
|
||||
assert revised_id != evidence_id and evidence_id in repository.evidence
|
||||
assert repository.evidence[revised_id]["data_snapshot"]["data"]["verified_through"] == (returned + timedelta(minutes=30)).isoformat()
|
||||
evidence_id = revised_id
|
||||
|
||||
repository.task.update(status="running", agent_session_id="session-1")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
@@ -172,3 +172,27 @@ def test_get_latest_scada_readings_skips_database_for_empty_page():
|
||||
|
||||
assert asyncio.run(ScadaRepository.get_latest_scada_readings(conn, [])) == {}
|
||||
assert conn.cursor_instance.calls == []
|
||||
|
||||
|
||||
def test_recovery_read_selects_first_raw_frame_strictly_after_feedback_and_bounded_reference():
|
||||
ScadaRepository = _load_scada_repository()
|
||||
incident = datetime(2026, 7, 16, 0, 0, tzinfo=timezone.utc)
|
||||
feedback = incident + timedelta(hours=5, minutes=7)
|
||||
through = feedback + timedelta(hours=2)
|
||||
rows = [{"time": incident - timedelta(days=1), "device_id": "P1", "monitored_value": 100., "cleaned_value": None},
|
||||
{"time": incident + timedelta(hours=5, minutes=15), "device_id": "P1", "monitored_value": 100., "cleaned_value": None}]
|
||||
conn = _ReadingConnection(rows)
|
||||
result = asyncio.run(ScadaRepository.get_recovery_scada_readings(conn, ["P1"], incident, feedback, through))
|
||||
assert result == rows
|
||||
query, parameters = conn.cursor_instance.calls[0]
|
||||
assert "min(time)" in query and "time>%s AND time<=%s" in query
|
||||
assert "time>=%s AND time<%s" in query and "JOIN next_frame" in query
|
||||
assert parameters == (["P1"], feedback, through, ["P1"], incident - timedelta(days=15), incident, 100_001, ["P1"])
|
||||
|
||||
|
||||
def test_recovery_read_does_not_query_when_feedback_is_not_in_the_past():
|
||||
ScadaRepository = _load_scada_repository()
|
||||
at = datetime(2026, 7, 16, tzinfo=timezone.utc)
|
||||
conn = _ReadingConnection([])
|
||||
assert asyncio.run(ScadaRepository.get_recovery_scada_readings(conn, ["P1"], at, at, at)) == []
|
||||
assert conn.cursor_instance.calls == []
|
||||
|
||||
Reference in New Issue
Block a user