fix(scada): verify first sample after field completion
Generic Container CI/CD / test-build-publish (push) Failing after 47s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 47s

This commit is contained in:
2026-11-07 19:50:31 +08:00
parent ff8aba4964
commit dc83cf5c88
7 changed files with 131 additions and 71 deletions
+31 -6
View File
@@ -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():
+7 -6
View File
@@ -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")
+25 -1
View File
@@ -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 == []