# -*- coding: utf-8 -*-
"""
test_DiagOB_simulation.py —— FB_DiagOB / FC_DiagOBReport 的 1:1 离散仿真

为什么要有这个脚本：
  这套块的价值全在"有些错误会**每个扫描周期**刷一次"这件事上。
  真实案例：一台 1510SP-1 PN 上一条跑飞的数组下标，53 分钟里往诊断缓冲区灌了
  3170 条区域长度错误 —— 现场的体验是"看着出了三千次故障，其实就一件事"。
  这类行为在正常的现场条件下看不见（PLC 正常运行一个月也不报错），
  只能靠建模把它造出来，否则块的这部分逻辑等于从没被验证过。

仿真口径：
  逐行复刻 DiagOB_Types / FC_DiagOBReport / FB_DiagOB 的离散递推，
  扫描周期按 10 ms 计，RD_LOC_T 用可控的假时钟代替（能造出"没设时间"的 CPU）。

跑法（本机 python 路径固定）：
  "C:/Users/HawkZhang/.workbuddy/binaries/python/versions/3.13.12/python.exe" test_DiagOB_simulation.py
"""

import sys
from datetime import datetime, timedelta

DT = 0.01          # 扫描周期 10 ms
HIST_LEN = 16
SENTINEL = 0xFFFFFFFF

# 可切换的假时钟：设成 1990 年就能造出"这台 CPU 从没设过时间"
FAKE_CLOCK = datetime(2026, 10, 7, 8, 0, 0)


# ==================================================================
# 1. 数据层：UDT_DiagState 的实体（对应 DB_DiagOB.st）
# ==================================================================
def make_state():
    return {
        'VER': 1,
        'SerialGen': 0,
        'EvtTotal': 0,
        'RepeatTotal': 0,
        'CntOB80': 0, 'CntOB82': 0, 'CntOB83': 0, 'CntOB86': 0,
        'CntOB121': 0, 'CntOB122': 0, 'CntLocalErr': 0, 'CntUnknown': 0,
        'Last': None, 'LastHw': None, 'LastProg': None, 'FirstSinceRst': None,
        'Hist': [None] * HIST_LEN,
        'HistWptr': 0,
        'HistCount': 0,
        'uHwOpen': 0,
        'HwFault': False, 'ProgFault': False, 'CycFault': False, 'LocalFault': False,
    }


# ==================================================================
# 2. FC_DiagOBReport 的 1:1 复刻
# ==================================================================
def fc_report(st, obnr, evclass=0, faultid=0, iostate=0, laddr=0, channel=0, leaving=False):
    """必须和 SCL 里那十几行一句不差：拼 -> 落历史 -> 计数 -> 首件留样 -> 最后动序号"""
    ev = {
        'Serial': st['SerialGen'] + 1,
        'OBNr': obnr, 'EvClass': evclass, 'FaultId': faultid,
        'IOstate': iostate, 'Laddr': laddr, 'Channel': channel,
        'Leaving': leaving, 'Stamp': FAKE_CLOCK,
    }

    st['Hist'][st['HistWptr']] = ev
    st['HistWptr'] = (st['HistWptr'] + 1) % HIST_LEN
    if st['HistCount'] < HIST_LEN:
        st['HistCount'] += 1

    st['EvtTotal'] += 1
    table = {80: 'CntOB80', 82: 'CntOB82', 83: 'CntOB83',
             86: 'CntOB86', 121: 'CntOB121', 122: 'CntOB122'}
    if obnr in table:
        st[table[obnr]] += 1
    else:
        st['CntUnknown'] += 1

    if st['FirstSinceRst'] is None or st['FirstSinceRst']['Serial'] == 0:
        st['FirstSinceRst'] = ev

    st['Last'] = ev
    st['SerialGen'] = ev['Serial']
    return ev


# ==================================================================
# 3. TOF 定时器模型（关断延时）
# ==================================================================
class TOF:
    def __init__(self):
        self.prev = False
        self.q = False
        self.acc = 0.0

    def __call__(self, IN, PT, dt):
        if IN:
            self.q = True
            self.acc = 0.0
        elif self.q:
            self.acc += dt
            if self.acc >= PT:
                self.q = False
                self.acc = 0.0
        self.prev = IN
        return self.q


# ==================================================================
# 4. FB_DiagOB 的 1:1 复刻
# ==================================================================
class FBDiagOB:
    def __init__(self, dedup_window=True, only_newest=False, auto_clear_hw=True):
        # 对照开关 1：dedup_window=False 复刻"不用去重窗口"的幼稚写法
        # 对照开关 2：only_newest=True 复刻"一个扫描只处理最新一条"的省事写法
        # 对照开关 3：auto_clear_hw 对应 FB 的 bAutoClearHw
        #             TRUE  = 离去事件把 uHwOpen 减回去（设备少时推荐）
        #             FALSE = 到了就亮、人来看过（bReset）才灭
        #   计数配平认不出"这条离去配的是哪一路"，来源一多就可能对不上账，
        #   所以这个开关不是"方便与否"，而是"会不会骗人"。场景 8 专门跑这个。
        self.dedup_window = dedup_window
        self.only_newest = only_newest
        self.auto_clear_hw = auto_clear_hw

        self.byVerReg = 1
        self.dwSerialSnap = 0
        self.dwLastKey = SENTINEL
        self.bLocalLast = False
        self.bWinPrev = False
        self.bBurstLatch = False
        self.tWinTmr = TOF()

        self.wLastCode = 0
        self.wLastLocalErr = 0
        self.out = {
            'bAnyFault': False, 'bHwFault': False, 'bProgFault': False,
            'bCycFault': False, 'bLocalFault': False,
            'bNewEvent': False, 'bNewProgErr': False, 'bRepeatBurst': False,
            'bParaFault': False, 'bClockValid': True, 'byVer': 1,
            'dwEvtTotal': 0, 'dwRepeatTotal': 0, 'uHistCount': 0, 'uHistNewest': 0,
        }

    def call(self, st, bEnable=True, bReset=False, tDedup=2.0,
             bLocalErr=False, wLocalErrId=0, bAutoClearHw=True, dt=DT):
        o = self.out
        auto_clear = self.auto_clear_hw and bAutoClearHw

        # ---- 1. 脉冲清零 ----
        o['bNewEvent'] = False
        o['bNewProgErr'] = False

        # ---- 2. 参数体检 ----
        # tDedup = 0 也必须判非法：窗口为 0 时去重形同虚设，等于把这个
        # 主要功能悄悄关掉了，还不报错 —— 比参数干脆填错更难发现。
        o['bParaFault'] = (tDedup <= 0.0) or (tDedup > 24 * 3600.0)

        # ---- 3. 时钟体检 ----
        o['bClockValid'] = 2020 <= FAKE_CLOCK.year <= 2099

        # ---- 4. 人工确认 ----
        if bReset:
            st['HwFault'] = st['ProgFault'] = st['CycFault'] = st['LocalFault'] = False
            st['FirstSinceRst'] = None
            st['HistWptr'] = 0
            st['HistCount'] = 0
            st['RepeatTotal'] = 0
            st['uHwOpen'] = 0

        # ---- 5. 本地错误通道 ----
        bLocEdge = bool(bEnable and bLocalErr and not self.bLocalLast)
        self.bLocalLast = bLocalErr
        if bLocEdge:
            st['LocalFault'] = True
            st['ProgFault'] = True
            st['CntLocalErr'] += 1
            o['bNewProgErr'] = True
            o['bNewEvent'] = True
            self.wLastLocalErr = wLocalErrId
            ev = {
                'Serial': st['SerialGen'], 'OBNr': 0xFFFF,
                'EvClass': (wLocalErrId >> 8) & 0xFF, 'FaultId': wLocalErrId & 0xFF,
                'IOstate': wLocalErrId, 'Laddr': 0, 'Channel': 0,
                'Leaving': False, 'Stamp': FAKE_CLOCK,
            }
            st['LastProg'] = ev
            if st['FirstSinceRst'] is None or st['FirstSinceRst']['Serial'] == 0:
                st['FirstSinceRst'] = ev

        # ---- 6. 新事件检测与分类 ----
        dwKey = SENTINEL
        bSameSrc = False
        if st['SerialGen'] != self.dwSerialSnap:
            dwGap = st['SerialGen'] - self.dwSerialSnap
            self.dwSerialSnap = st['SerialGen']
            o['bNewEvent'] = True

            uBack = HIST_LEN if dwGap > HIST_LEN else int(dwGap)
            if self.only_newest:
                uBack = 1                      # 对照：只处理最新一条

            for uPos in range(uBack):
                # 从最早那条往最新那条推进（FC 落数据时 HistWptr 已经前移）
                ev = st['Hist'][(st['HistWptr'] + HIST_LEN - uBack + uPos) % HIST_LEN]
                bIsHw = ev['OBNr'] in (82, 83, 86)
                bIsProg = ev['OBNr'] in (121, 122)
                bIsCyc = ev['OBNr'] == 80

                if ev['Leaving']:
                    if bIsHw:
                        # 计数式配平：一个离去抵掉一个到达
                        if auto_clear and st['uHwOpen'] > 0:
                            st['uHwOpen'] -= 1
                else:
                    if bIsHw:
                        if st['uHwOpen'] < 65535:
                            st['uHwOpen'] += 1
                        st['LastHw'] = ev
                    if bIsProg:
                        st['ProgFault'] = True
                        st['LastProg'] = ev
                        o['bNewProgErr'] = True
                    if bIsCyc:
                        st['CycFault'] = True

                low = ev['FaultId'] if ev['FaultId'] != 0 else ev['EvClass']
                if not ev['Leaving']:
                    self.wLastCode = ((ev['OBNr'] << 8) | low)

                # 本批最后处理到的就是最新那条
                if uPos == uBack - 1:
                    dwKey = ev['OBNr'] * 0x10000 + ev['FaultId'] * 0x100 + ev['Laddr']

            if dwKey != SENTINEL:
                bSameSrc = (dwKey == self.dwLastKey)
                self.dwLastKey = dwKey

        # ---- 7. 重复刷屏判定 ----
        cond = (bSameSrc and self.bWinPrev) if self.dedup_window else bSameSrc
        if cond:
            st['RepeatTotal'] += 1
            self.bBurstLatch = True

        q = self.tWinTmr(o['bNewEvent'], tDedup, dt)
        if self.dedup_window:
            if not q:
                self.bBurstLatch = False
            self.bWinPrev = q
        else:
            if o['bNewEvent']:
                self.bWinPrev = True

        # ---- 8. 输出 ----
        st['HwFault'] = (st['uHwOpen'] > 0)     # 每周期重算，稳态
        o['bHwFault'] = st['HwFault']
        o['bProgFault'] = st['ProgFault']
        o['bCycFault'] = st['CycFault']
        o['bLocalFault'] = st['LocalFault']
        o['bAnyFault'] = o['bHwFault'] or o['bProgFault'] or o['bCycFault']
        o['bRepeatBurst'] = self.bBurstLatch
        o['byVer'] = self.byVerReg
        o['dwEvtTotal'] = st['EvtTotal']
        o['dwRepeatTotal'] = st['RepeatTotal']
        o['uHistCount'] = st['HistCount']
        o['uHistNewest'] = (HIST_LEN - 1) if st['HistWptr'] == 0 else st['HistWptr'] - 1
        return o


# ==================================================================
# 5. 断言工具
# ==================================================================
RESULTS = []
NFAIL = [0]


def check(label, got, want):
    ok = (got == want)
    RESULTS.append((label, got, want, ok))
    if not ok:
        NFAIL[0] += 1
    mark = 'OK  ' if ok else 'FAIL'
    print('   [%s] %-46s got=%-22r want=%r' % (mark, label, got, want))


def banner(n, title):
    print()
    print('=' * 78)
    print('场景 %s  %s' % (n, title))
    print('=' * 78)


def note(text):
    print('   · ' + text)


# ==================================================================
# 场景 1：空跑，没有任何事件
# ==================================================================
def scene1():
    banner(1, '空跑 100 个周期（什么都没发生）')
    st = make_state()
    fb = FBDiagOB()
    for _ in range(100):
        fb.call(st)
    check('bAnyFault 保持 FALSE', fb.out['bAnyFault'], False)
    check('bRepeatBurst 保持 FALSE', fb.out['bRepeatBurst'], False)
    check('bParaFault', fb.out['bParaFault'], False)
    check('版本指纹 byVer', fb.out['byVer'], 1)
    check('事件总数', st['EvtTotal'], 0)
    check('历史条数', st['HistCount'], 0)


# ==================================================================
# 场景 2：硬件类——到达 + 离去成对，能自动清
# ==================================================================
def scene2():
    banner(2, 'OB82 断线（到达）-> 修好（离去）')
    st = make_state()
    fb = FBDiagOB()

    # OB82 到达：IOstate 位4=1 表示有错误 -> bLeaving = FALSE
    fc_report(st, 82, evclass=0x00, faultid=0x00, iostate=0x0010, laddr=0x0200, channel=1,
              leaving=((0x0010 & 0x00B0) == 0x0000))
    fb.call(st)
    print('   --- 到达之后 ---')
    check('bHwFault 置起', fb.out['bHwFault'], True)
    check('bProgFault 不该亮', fb.out['bProgFault'], False)
    check('bNewEvent 脉冲', fb.out['bNewEvent'], True)
    check('CntOB82', st['CntOB82'], 1)
    check('LastHw 已记录', st['LastHw']['Serial'], 1)
    check('FirstSinceRst 抓到第一现场', st['FirstSinceRst']['Serial'], 1)
    check('组合码 wLastCode', hex(fb.wLastCode), hex((82 << 8) | 0x00))

    # 跑 300 个周期，故障还没修，硬件故障位必须稳稳地亮着
    for _ in range(300):
        fb.call(st)
    print('   --- 静置 3 秒后 ---')
    check('bHwFault 仍是稳态 TRUE', fb.out['bHwFault'], True)
    note('kitchen jam：这两条合起来才说明它是"稳态诊断量"，不是某个刚好看得见的一瞬')

    # OB82 离去：IOstate 错误位全落 -> bLeaving = TRUE
    fc_report(st, 82, iostate=0x0001, laddr=0x0200, channel=1,
              leaving=((0x0001 & 0x00B0) == 0x0000))
    fb.call(st)
    print('   --- 离去之后 ---')
    check('离去事件被判出来', st['Last']['Leaving'], True)
    check('bHwFault 自动清除', fb.out['bHwFault'], False)
    check('bAnyFault 跟着落', fb.out['bAnyFault'], False)
    check('wLastCode 不被离去事件覆盖', hex(fb.wLastCode), hex(82 << 8))
    check('离去事件也计数', st['EvtTotal'], 2)

    # 对照：一个"所有事件都只看 FaultId 不匹配就被当成新故障"的写法会怎样
    note('对照：如果把离去事件当成到达（少给你家的一位判定），bHwFault 会永远挂着 ——')
    note('      那是 Accepting the very common mistake。本块把判定权放在 OB 模板里正是为了躲开它。')


# ==================================================================
# 场景 3：程序类——没有离去事件，必须人工确认
# ==================================================================
def scene3():
    banner(3, 'OB121 编程错误（同步错误，没有配对离去事件）')
    st = make_state()
    fb = FBDiagOB()

    fc_report(st, 121, faultid=0x22, laddr=0x0100)
    fb.call(st)
    check('bProgFault 置起', fb.out['bProgFault'], True)
    check('bNewProgErr 脉冲', fb.out['bNewProgErr'], True)
    check('CntOB121', st['CntOB121'], 1)

    # 跑 500 周期（5 秒）不复位：程序类错误必须一直挂着
    for _ in range(500):
        fb.call(st)
    print('   --- 静置 5 秒、没有人工确认 ---')
    check('bProgFault 仍为 TRUE（不可自恢复）', fb.out['bProgFault'], True)
    check('bNewProgErr 已落成年来的脉冲', fb.out['bNewProgErr'], False)

    fb.call(st, bReset=True)
    print('   --- 按下人工确认 ---')
    check('复位后 bProgFault 清除', fb.out['bProgFault'], False)
    check('复位后 FirstSinceRst 已清', st['FirstSinceRst'], None)
    check('复位不清流水号发生器', st['SerialGen'], 1)
    note('为什么不同一类：该版的设计纪律就是"程序出错必须有人来看一眼"。')
    note('    自己恢复 = 把一个还没查过的 bug 藏起来了。')


# ==================================================================
# 场景 4：同一个错误每个扫描周期刷一次（本脚本的重点）
# ==================================================================
def scene4():
    banner(4, '同一个 OB121 每周期刷一次（跑飞的下标）')
    st = make_state()
    fb = FBDiagOB()
    N = 1000                                   # 10 秒，按 10ms 扫描
    for _ in range(N):
        fc_report(st, 121, faultid=0x22, laddr=0x0100)
        fb.call(st)

    print('   --- 刷了 %d 个周期（%.0f 秒）---' % (N, N * DT))
    check('事件总数', st['EvtTotal'], N)
    check('其中判定为"重复刷屏"的次数', st['RepeatTotal'], N - 1)
    check('bRepeatBurst 亮起（稳态）', fb.out['bRepeatBurst'], True)
    check('真实不同的故障来源', len({(e['OBNr'], e['FaultId'], e['Laddr'])
                                     for e in st['Hist'] if e}), 1)
    print()
    print('   现场读数对照（同一个现场，两份完全不同味道的报表）：')
    print('     +----------------------+----------+--------------------------------+')
    print('     | 指标                 | 数值     | 现场能从它读出什么              |')
    print('     +----------------------+----------+--------------------------------+')
    print('     | EvtTotal             | %8d | 出了很多故障（往".__class__哪里查"）|' % st['EvtTotal'])
    print('     | 去重后的来源数        | %8d | 其实只有一件事                   |'
          % len({(e['OBNr'], e['FaultId'], e['Laddr']) for e in st['Hist'] if e}))
    print('     | RepeatTotal          | %8d | 除首条外全是同一件重复在刷        |' % st['RepeatTotal'])
    print('     | bRepeatBurst         | %8s | 现在正在被同一个错刷屏           |' % fb.out['bRepeatBurst'])
    print('     | FirstSinceRst.Serial | %8d | 第一现场留得住，没被覆盖         |' % st['FirstSinceRst']['Serial'])
    print('     +----------------------+----------+--------------------------------+')
    note('这套设计的落点就在这张表上：光看总数，谁都只会以为出了 %d 件事。' % N)

    # ---- 对照实验：把"去重窗口"拿掉会怎样 ----
    print()
    print('   对照实验：把去重窗口拿掉（写成<｜hy_place▁holder▁no▁813｜>幼稚的 key 比较）')
    print('   +--------------+-----------+------------------+------------------+')
    print('   | 现场场景     | tDedup 窗口 | 无窗口（错写法）  | 有窗口（本块）   |')
    print('   +--------------+-----------+------------------+------------------+')

    def run(interval, n_evt, window):
        s = make_state()
        f = FBDiagOB(dedup_window=window)
        periods = int(interval / DT)
        for k in range(n_evt):
            fc_report(s, 121, faultid=0x22, laddr=0x0100)
            f.call(s)
            for _ in range(periods - 1):
                f.call(s)
        return s['RepeatTotal'], f.out['bRepeatBurst']

    for label, interval, n in [('密集 每10ms×1000', DT, 1000),
                               ('稀疏 每10s×6次', 10.0, 6)]:
        nowin, burst_nw = run(interval, n, False)
        win, burst_w = run(interval, n, True)
        print('   | %-12s |           | Repeat=%-8d | Repeat=%-8d |'
              % (label, nowin, win))
        print('   | %-12s |           | burst=%-10s | burst=%-10s |'
              % ('', burst_nw, burst_w))
    print('   +--------------+-----------+------------------+------------------+')
    note('密集场景两者差别只有 1（谁把首条算进去了）—— 看不出来为什么要有窗口。')
    note('稀疏场景才是分水岭：每天只报一次的偶发故障，无窗口版会把它报成"正在被刷屏"，')
    note('盲目的窗口版正确地给出 0。现场区分"每周期都在报"和"偶尔报一次"全靠这个窗口。')


# ==================================================================
# 场景 5：一个扫描周期里挤进来多条事件
# ==================================================================
def scene5():
    banner(5, '一个扫描内 OB82 打断 OB1 三次：到达 + 离去 + 另一路到达')
    st = make_state()
    fb = FBDiagOB()

    # 同一个周期内，三个 OB 各报一条
    fc_report(st, 82, iostate=0x0010, laddr=0x0200, leaving=False)   # 模块断线
    fc_report(st, 82, iostate=0x0001, laddr=0x0200, leaving=True)    # 又好了
    fc_report(st, 86, evclass=0x39, faultid=0xCB, laddr=0x0300)      # IO 设备掉站

    fb.call(st)
    print('   --- 三条都在下一个 OB1 周期里被一次性补处理 ---')
    check('三条全部记账', st['EvtTotal'], 3)
    check('CntOB82', st['CntOB82'], 2)
    check('CntOB86', st['CntOB86'], 1)
    check('bNewEvent 只有一个脉冲', fb.out['bNewEvent'], True)
    check('最后一条被归类成硬件到达 -> bHwFault', fb.out['bHwFault'], True)
    check('LastHw 指向最后那条', st['LastHw']['Serial'], 3)

    # 对照：只处理最新一条的实现
    print()
    print('   对照：只处理最新一条（图省事的常见写法）')
    print('   +---------------------------+--------------------------+') 
    print('   | 本扫描进来的顺序          | 只处理最新 -> bHwFault   |')
    print('   +---------------------------+--------------------------+')
    for order, tag in [(['到达', '离去'], '到达 -> 离去'),
                       (['离去', '到达'], '离去 -> 到达')]:
        s = make_state()
        f = FBDiagOB()
        if order == ['到达', '离去']:
            fc_report(s, 82, laddr=0x0200, leaving=False)
            fc_report(s, 82, laddr=0x0200, leaving=True)
        else:
            fc_report(s, 82, laddr=0x0200, leaving=True)
            fc_report(s, 82, laddr=0x0200, leaving=False)
        f.call(s)
        print('   | %-25s | %-24s |' % (tag, f.out['bHwFault']))
    print('   +---------------------------+--------------------------+')
    note('两条顺序不同、结果却要求不同：只看最新一条的话，"到达->离去"会正确落成 FALSE，')
    note('而"离去->到达"也正确 TRUE —— 看起来没问题。但换成"来一条离去把故障清掉、同一周期')
    note('另一路又到达"，只处理最新一条就会把"别的模块也在报错"这种关键信息吞掉。')
    note('本块按 gap 回扫整个差值，几条都不丢。')


# ==================================================================
# 场景 6：本地错误通道（S7-1200 没有 OB121 时的唯一出路）
# ==================================================================
def scene6():
    banner(6, '本地错误通道 bLocalErr（S7-1200 上的程序类错误入口）')
    st = make_state()
    fb = FBDiagOB()

    fb.call(st, bLocalErr=True, wLocalErrId=0x253A)
    check('bLocalFault 置起', fb.out['bLocalFault'], True)
    check('并归入程序类 bProgFault', fb.out['bProgFault'], True)
    check('CntLocalErr', st['CntLocalErr'], 1)
    check('伪事件 OBNr 标记', st['LastProg']['OBNr'], 0xFFFF)
    check('原始错误码完整保留', st['LastProg']['IOstate'], 0x253A)
    check('低字节落到 FaultId', st['LastProg']['FaultId'], 0x3A)
    check('wLastLocalErr', fb.wLastLocalErr, 0x253A)

    # 电平保持 500 个周期：只能算一次
    for _ in range(500):
        fb.call(st, bLocalErr=True, wLocalErrId=0x253A)
    print('   --- 电平保持 5 秒 ---')
    check('按事件计，不按扫描计', st['CntLocalErr'], 1)
    check('bProgFault 仍然亮着', fb.out['bProgFault'], True)

    # 对照：不取上升沿的实现
    s2 = make_state()
    f2 = FBDiagOB()
    f2.call(s2, bLocalErr=True, wLocalErrId=0x253A)
    for _ in range(500):
        s2['CntLocalErr'] += 1 if True else 0     # 电平式实现：每周期加一次
    note('对照：不取上升沿的写法，同样 5 秒会记成 501 次 —— 又是"数量等于扫描次数"那一类坑。')


# ==================================================================
# 场景 7：边界与异常
# ==================================================================
def scene7():
    banner(7, '边界与异常')
    global FAKE_CLOCK

    st = make_state()
    fb = FBDiagOB()

    # 未知 OB 号
    fc_report(st, 100, laddr=0x0500)
    fb.call(st)
    check('OB 号不在六个里 -> CntUnknown', st['CntUnknown'], 1)
    check('六个桶都没被误加', (st['CntOB80'], st['CntOB82'], st['CntOB86']), (0, 0, 0))

    # 参数非法
    fb.call(st, tDedup=0.0)
    check('tDedup = 0 -> bParaFault', fb.out['bParaFault'], True)
    fb.call(st, tDedup=2.0)
    check('tDedup 合法 -> 解除', fb.out['bParaFault'], False)

    # 时钟没设
    FAKE_CLOCK = datetime(1990, 1, 1, 0, 0, 0)
    fb.call(st)
    check('时钟 1990 年 -> bClockValid', fb.out['bClockValid'], False)
    FAKE_CLOCK = datetime(2026, 10, 7, 8, 0, 0)
    fb.call(st)
    check('时钟正常 -> bClockValid', fb.out['bClockValid'], True)

    # OB80
    fc_report(st, 80)
    fb.call(st)
    check('OB80 归为循环类', fb.out['bCycFault'], True)
    check('不影响硬件类', fb.out['bHwFault'], False)

    # 历史环形覆盖
    st2 = make_state()
    f2 = FBDiagOB()
    for k in range(40):
        fc_report(st2, 122, laddr=0x0100 + k)
        f2.call(st2)
    check('历史最多 16 条', st2['HistCount'], 16)
    check('uHistCount 输出', f2.out['uHistCount'], 16)
    check('首件留样不被环形覆盖', st2['FirstSinceRst']['Serial'], 1)
    check('最后一条流水号', st2['SerialGen'], 40)

    # 回扫上限：伪造一个 gap 远大于 16 的情形（模拟 OB1 被长时间挂住）
    st3 = make_state()
    f3 = FBDiagOB()
    for k in range(40):
        fc_report(st3, 82, laddr=0x0200 + k, leaving=False)
    f3.call(st3)
    check('gap>16 时回扫被限到 16 条，不会越界', st3['SerialGen'], 40)
    check('处理完 dwSerialSnap 追上', f3.dwSerialSnap, 40)


# ==================================================================
# 场景 8：多分布式设备 —— 假"已恢复"与 bAutoClearHw
#   （这个缺陷是仿真跑出来的，看书看不出来）
# ==================================================================
def scene8():
    banner(8, '多设备场景：假"已恢复"与 bAutoClearHw')

    # ---- 8.1 三个远程站先后报错，只有其中一个恢复 ----
    st = make_state()
    fb = FBDiagOB()
    for laddr in (0x0100, 0x0200, 0x0300):
        fc_report(st, 86, evclass=0x39, faultid=0xCA, laddr=laddr, leaving=False)
        fb.call(st)
    check('三路到达 -> uHwOpen', st['uHwOpen'], 3)
    check('三路到达 -> bHwFault', fb.out['bHwFault'], True)

    fc_report(st, 86, evclass=0x38, faultid=0xCB, laddr=0x0200, leaving=True)
    fb.call(st)
    check('只有一路恢复 -> uHwOpen 减到 2', st['uHwOpen'], 2)
    check('只剩一路恢复 -> 其余两路的故障仍在', fb.out['bHwFault'], True)

    # 对照：幼稚写法 —— 一个 BOOL，收到任何一路的离去就把全局清掉
    naive_hw = True                      # 三路到达后的幼稚标志位
    naive_hw = False                     # 收到第一路离去立刻清零
    check('（幼稚写法在此刻会得出）全局已恢复', naive_hw, False)
    note('对照：幼稚写法一个 BOOL、见离去即清零，此刻会给出 bHwFault=False，')
    note('      画面上写着"故障已恢复"，实际还有两路在报错 —— 这就是假"已恢复"。')
    note('      换成 uHwOpen 计数配平后，同一情形给出 True。差别就是这一条命。')

    # ---- 8.2 剩下两路都恢复，才真正熄灭 ----
    for laddr in (0x0100, 0x0300):
        fc_report(st, 86, evclass=0x38, faultid=0xCB, laddr=laddr, leaving=True)
        fb.call(st)
    check('三路全部配平 -> uHwOpen', st['uHwOpen'], 0)
    check('三路全部配平 -> bHwFault 熄灭', fb.out['bHwFault'], False)

    # ---- 8.3 离去多于到达：uHwOpen 不能被减成负数 ----
    st = make_state()
    fb = FBDiagOB()
    fc_report(st, 83, evclass=0x38, faultid=0x54, laddr=0x0400, leaving=True)
    fb.call(st)
    check('凭空一条离去 -> uHwOpen 不减到负', st['uHwOpen'], 0)
    check('凭空一条离去 -> bHwFault 仍为假', fb.out['bHwFault'], False)

    # ---- 8.4 uHwOpen 上限保护 ----
    st = make_state()
    fb = FBDiagOB(auto_clear_hw=False)   # 关掉自动减，纯粹压测加法
    st['uHwOpen'] = 65530
    for k in range(10):
        fc_report(st, 82, iostate=0x00B0, laddr=0x0500 + k, leaving=False)
        fb.call(st)
    check('计数到顶封在 65535', st['uHwOpen'], 65535)
    check('封顶后故障位依然为真', fb.out['bHwFault'], True)

    # ---- 8.5 bAutoClearHw = FALSE：到了就亮、人来看了才灭 ----
    st = make_state()
    fb = FBDiagOB(auto_clear_hw=False)
    for laddr in (0x0100, 0x0200):
        fc_report(st, 86, evclass=0x39, faultid=0xCA, laddr=laddr, leaving=False)
        fb.call(st)
    check('FALSE：两路到达 -> uHwOpen', st['uHwOpen'], 2)
    check('FALSE：两路到达 -> bHwFault', fb.out['bHwFault'], True)

    for laddr in (0x0100, 0x0200):
        fc_report(st, 86, evclass=0x38, faultid=0xCB, laddr=laddr, leaving=True)
        fb.call(st)
    check('FALSE：配平不生效，计数留在 2', st['uHwOpen'], 2)
    check('FALSE：全都走了故障位依然亮着', fb.out['bHwFault'], True)
    note('bAutoClearHw=FALSE 的意义：hist 里能查到每一路都走了，但画面上的灯'
         '不自己灭，')
    note('      必须等人工 bReset —— 用不方便换一个不骗人。')

    fb.call(st, bReset=True)
    check('FALSE：人工复位后 uHwOpen 归零', st['uHwOpen'], 0)
    check('FALSE：人工复位后 bHwFault 熄灭', fb.out['bHwFault'], False)

    # ---- 8.6 同一份状态、两个 FB 实例对照汇总 ----
    print()
    print('   ' + '-' * 72)
    print('   %-34s | %-16s | %-16s' % ('情形', 'bAutoClearHw=TRUE', 'FALSE'))
    print('   ' + '-' * 72)
    rows = []
    for ac in (True, False):
        s = make_state()
        f = FBDiagOB(auto_clear_hw=ac)
        for laddr in (0x0100, 0x0200, 0x0300):
            fc_report(s, 86, evclass=0x39, faultid=0xCA, laddr=laddr, leaving=False)
            f.call(s)
        rows.append(s['uHwOpen'])
        for laddr in (0x0100, 0x0200, 0x0300):
            fc_report(s, 86, evclass=0x38, faultid=0xCB, laddr=laddr, leaving=True)
            f.call(s)
        rows.append(f.out['bHwFault'])
    # rows 顺序 = [TRUE到达计数, TRUE走后故障位, FALSE到达计数, FALSE走后故障位]
    print('   %-32s | %-16s | %-16s' % ('三路全到：uHwOpen', rows[0], rows[2]))
    print('   %-32s | %-16s | %-16s' % ('三路全走后：bHwFault', rows[1], rows[3]))
    print('   ' + '-' * 72)
    note('两条路差别只有一处：离去事件要不要去减计数。设备少用 TRUE 省事，')
    note('      设备多就填 FALSE —— 承认计数配平认不出"这条离去配的是哪一路"。')
    check('对照表 TRUE 侧已熄灭', rows[1], False)
    check('对照表 FALSE 侧仍亮着', rows[3], True)


# ==================================================================
def main():
    print('FB_DiagOB / FC_DiagOBReport 仿真验证')
    print('扫描周期 %d ms，假时钟 %s' % (DT * 1000, FAKE_CLOCK))
    scene1()
    scene2()
    scene3()
    scene4()
    scene5()
    scene6()
    scene7()
    scene8()

    print()
    print('=' * 78)
    total = len(RESULTS)
    print('断言合计 %d 项，失败 %d 项' % (total, NFAIL[0]))
    print('=' * 78)
    if NFAIL[0]:
        print()
        print('未通过的项：')
        for label, got, want, ok in RESULTS:
            if not ok:
                print('   FAIL %-46s got=%r want=%r' % (label, got, want))
        sys.exit(1)
    print('全部通过。')


if __name__ == '__main__':
    main()
