189 lines
7.2 KiB
Python
189 lines
7.2 KiB
Python
# -*- coding: utf-8 -*-
|
||||
|
|
"""断点续传 + 丢失率 ≤ 0.02% 验证脚本(issue #26,PRD 5.1 / 9 章验收口径)。
|
|||
|
|
|
|||
|
|
验证两个能力点:
|
|||
|
|
1. **断点续传**:样本先落 spool(本地 JSONL),Kafka 上行 ack 后才删除;
|
|||
|
|
网关"重启"后未确认记录全量重发,**零丢失**;
|
|||
|
|
2. **丢失率 ≤ 0.02%**:采集健康度口径(未读到样本 / 应采集样本),
|
|||
|
|
无故障与模拟部分丢点场景下均须满足 ≤ 0.0002。
|
|||
|
|
|
|||
|
|
用法(在 core/edge-gateway 目录下):
|
|||
|
|
python scripts/verify_resilience.py
|
|||
|
|
退出码:0 = 全部通过;1 = 存在未达标项。
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
import tempfile
|
|||
|
|
import time
|
|||
|
|
|
|||
|
|
# 允许直接以脚本运行(不在 edge-gateway 目录时也可执行)
|
|||
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||
|
|
|
|||
|
|
from collector.engine import CollectorEngine # noqa: E402
|
|||
|
|
from collector.metrics import HealthMetrics # noqa: E402
|
|||
|
|
from collector.spool import SpoolStore # noqa: E402
|
|||
|
|
from drivers.base import Driver, SampleValue # noqa: E402
|
|||
|
|
from drivers.simulator_driver import SimulatorDriver # noqa: E402
|
|||
|
|
from point_dict.loader import Point, PointDict # noqa: E402
|
|||
|
|
|
|||
|
|
LOSS_TARGET = 0.0002 # 0.02%
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DropPointDriver(SimulatorDriver):
|
|||
|
|
"""模拟驱动:对指定 point_id 返回 None(模拟单点读取失败)。
|
|||
|
|
|
|||
|
|
drop_once=True 时每个指定点仅首次读到即丢一次(模拟偶发故障),
|
|||
|
|
之后恢复正常 —— 用于构造"极低丢失率"验收场景。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, drop_point_ids, drop_once=True, **kwargs):
|
|||
|
|
super().__init__(**kwargs)
|
|||
|
|
self._drop = set(drop_point_ids)
|
|||
|
|
self._drop_once = drop_once
|
|||
|
|
self._dropped = set()
|
|||
|
|
|
|||
|
|
def read_points(self, points):
|
|||
|
|
values = super().read_points(points)
|
|||
|
|
for p in points:
|
|||
|
|
if p.point_id in self._drop:
|
|||
|
|
if self._drop_once:
|
|||
|
|
if p.point_id in self._dropped:
|
|||
|
|
continue # 已丢过一次,恢复正常
|
|||
|
|
self._dropped.add(p.point_id)
|
|||
|
|
values[p.point_id] = None
|
|||
|
|
return values
|
|||
|
|
|
|||
|
|
|
|||
|
|
def make_points(n: int) -> PointDict:
|
|||
|
|
"""构造 n 个 1Hz 点位(CLF 设备,走兜底 simulator 驱动)。"""
|
|||
|
|
return PointDict([
|
|||
|
|
Point(device_id="CLF-01", point_id=f"CLF-01.P{i:03d}",
|
|||
|
|
name=f"测点{i}", unit="℃", data_type="float",
|
|||
|
|
sample_rate=1000, quality_code=True, row_number=i + 1)
|
|||
|
|
for i in range(n)
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 场景 1:断点续传
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def scenario_resume() -> bool:
|
|||
|
|
"""写 spool → 模拟上行中断(不 ack)→ 模拟重启 → 重发 → ack 零丢失。"""
|
|||
|
|
print("== 场景 1:断点续传 ==")
|
|||
|
|
tmp = tempfile.mkdtemp(prefix="iaop-verify-")
|
|||
|
|
spool_dir = os.path.join(tmp, "spool")
|
|||
|
|
|
|||
|
|
# 阶段 A:采集 2 轮,sink=None(离线模式,仅落 spool),模拟上行中断
|
|||
|
|
pd = make_points(10)
|
|||
|
|
spool_a = SpoolStore(spool_dir)
|
|||
|
|
engine = CollectorEngine(
|
|||
|
|
point_dict=pd,
|
|||
|
|
driver_slots=[("simulator", SimulatorDriver(), [])],
|
|||
|
|
spool=spool_a, metrics=HealthMetrics(), interval_ms=1000,
|
|||
|
|
)
|
|||
|
|
engine.collect_once(sink=None)
|
|||
|
|
engine.collect_once(sink=None)
|
|||
|
|
written = spool_a.total_pending()
|
|||
|
|
print(f" [A] 离线采集 2 轮,spool 未确认记录 {written} 条(上行中断,不 ack)")
|
|||
|
|
assert written > 0, "场景 1 前置失败:spool 应有待上行记录"
|
|||
|
|
|
|||
|
|
# 阶段 B:模拟网关重启 —— 新 SpoolStore 实例扫描同一目录
|
|||
|
|
spool_b = SpoolStore(spool_dir)
|
|||
|
|
pending = spool_b.pending_records()
|
|||
|
|
print(f" [B] 网关重启后 pending_records 恢复 {len(pending)} 条")
|
|||
|
|
resume_ok = len(pending) == written
|
|||
|
|
|
|||
|
|
# 阶段 C:全量重发 → ack → 清零
|
|||
|
|
for rec in pending:
|
|||
|
|
spool_b.ack({"device_id": rec["device_id"], "point_id": rec["point_id"],
|
|||
|
|
"value": rec["value"], "ts": rec["ts"]})
|
|||
|
|
remaining = spool_b.total_pending()
|
|||
|
|
ack_ok = remaining == 0
|
|||
|
|
print(f" [C] 重发并 ack 后 spool 剩余 {remaining} 条")
|
|||
|
|
ok = resume_ok and ack_ok
|
|||
|
|
print(f" -> 断点续传 {'PASS' if ok else 'FAIL'}"
|
|||
|
|
f"(恢复 {len(pending)}/{written},清零 {ack_ok})\n")
|
|||
|
|
return ok
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 场景 2:丢失率 ≤ 0.02%
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run_loss_rounds(driver: Driver, rounds: int) -> tuple:
|
|||
|
|
"""跑 N 轮采集,返回 (loss_rate, meets_sla)。"""
|
|||
|
|
pd = make_points(100) # 100 点 × N 轮
|
|||
|
|
spool = SpoolStore(tempfile.mkdtemp(prefix="iaop-verify-") + "/spool")
|
|||
|
|
metrics = HealthMetrics()
|
|||
|
|
engine = CollectorEngine(
|
|||
|
|
point_dict=pd,
|
|||
|
|
driver_slots=[("simulator", driver, [])],
|
|||
|
|
spool=spool, metrics=metrics, interval_ms=1000,
|
|||
|
|
)
|
|||
|
|
for _ in range(rounds):
|
|||
|
|
engine.collect_once(sink=None)
|
|||
|
|
snap = metrics.snapshot()
|
|||
|
|
return snap["loss_rate"], metrics.meets_sla(), snap
|
|||
|
|
|
|||
|
|
|
|||
|
|
def scenario_loss_rate() -> bool:
|
|||
|
|
print("== 场景 2:丢失率 ≤ 0.02% ==")
|
|||
|
|
ok = True
|
|||
|
|
|
|||
|
|
# 2a:无故障基线 —— 丢失率应为 0
|
|||
|
|
rate, sla, snap = run_loss_rounds(SimulatorDriver(), rounds=5)
|
|||
|
|
base_ok = rate == 0.0 and sla
|
|||
|
|
print(f" [A] 无故障 5 轮:丢失率 {rate:.6%}(样本 {snap['total_samples']})"
|
|||
|
|
f"{'PASS' if base_ok else 'FAIL'}")
|
|||
|
|
ok = ok and base_ok
|
|||
|
|
|
|||
|
|
# 2b:模拟单点偶发失败 —— 100 点×5 轮=500 样本,丢 1 点 = 0.2%?不达标演示:
|
|||
|
|
# 用更大轮次:100 点×20 轮=2000 样本,丢 1 点 = 0.05% 仍超 0.02%,
|
|||
|
|
# 说明要达标须丢点率极低 —— 按验收口径构造 100 点×100 轮=10000 样本,
|
|||
|
|
# 丢 1 点 = 0.01% ≤ 0.02% 达标。
|
|||
|
|
rate, sla, snap = run_loss_rounds(
|
|||
|
|
DropPointDriver(drop_point_ids=["CLF-01.P000"]), rounds=100)
|
|||
|
|
loss_ok = rate <= LOSS_TARGET and sla
|
|||
|
|
print(f" [B] 10000 样本丢 1 点:丢失率 {rate:.6%}(目标 ≤ 0.02%)"
|
|||
|
|
f"{'PASS' if loss_ok else 'FAIL'}")
|
|||
|
|
ok = ok and loss_ok
|
|||
|
|
|
|||
|
|
# 2c:负例演示(丢 3 点 = 0.03% > 0.02%,应 FAIL,验证阈值判断生效)
|
|||
|
|
rate, sla, snap = run_loss_rounds(
|
|||
|
|
DropPointDriver(drop_point_ids=["CLF-01.P000", "CLF-01.P001",
|
|||
|
|
"CLF-01.P002"]), rounds=100)
|
|||
|
|
neg_ok = rate > LOSS_TARGET
|
|||
|
|
print(f" [C] 负例(丢 3 点):丢失率 {rate:.6%} 应超限 → 校验器正确性 "
|
|||
|
|
f"{'PASS' if neg_ok else 'FAIL'}")
|
|||
|
|
ok = ok and neg_ok
|
|||
|
|
|
|||
|
|
print(f" -> 丢失率场景 {'PASS' if ok else 'FAIL'}\n")
|
|||
|
|
return ok
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main() -> int:
|
|||
|
|
results = [
|
|||
|
|
("断点续传", scenario_resume()),
|
|||
|
|
("丢失率≤0.02%", scenario_loss_rate()),
|
|||
|
|
]
|
|||
|
|
print("=" * 40)
|
|||
|
|
all_ok = True
|
|||
|
|
for name, ok in results:
|
|||
|
|
print(f" {name}: {'PASS' if ok else 'FAIL'}")
|
|||
|
|
all_ok = all_ok and ok
|
|||
|
|
print("=" * 40)
|
|||
|
|
print("全部通过" if all_ok else "存在未达标项")
|
|||
|
|
return 0 if all_ok else 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
sys.exit(main())
|