اگر خروجی هشدار فقط از مقایسهٔ ولتاژ با یک عدد ساخته شود، هر تغییر سمت آستانه می‌تواند خروجی را عوض کند. در هیسترزیس، دو آستانه و حافظهٔ حالت داریم؛ دانستن مقدار جاری همیشه برای تعیین خروجی کافی نیست. همین نکته، مسئلهٔ خوبی برای انتخاب آزمون مبتنی بر ویژگی است. در این درس، یک تصمیم‌گیر نرم‌افزاری را با ولتاژهای ساختگی، جواب دستی و مرجع مستقل می‌آزماییم؛ هیچ رله، حسگر یا کنترل‌گر واقعی راه‌اندازی نمی‌شود.

۱. دو آستانه و قرارداد دقیق تساوی

دو آستانهٔ جدا برای روشن و خاموش شدن
تصویر مفهومی: دو آستانهٔ جدا برای روشن و خاموش شدن؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

آستانهٔ پایین L را دو ولت و آستانهٔ بالا H را سه ولت می‌گیریم. شرط L<H الزامی است. اگر نمونه بزرگ‌تر یا مساوی H باشد، حالت روشن می‌شود؛ اگر کوچک‌تر یا مساوی L باشد، خاموش می‌شود. بین این دو، حالت قبلی حفظ می‌شود. خروجی یک bool منطقی است و معنی برق‌دار بودن پایه، تماس رله یا اجازهٔ کار دستگاه ندارد.

مستندات Relay در MathWorks وابستگی خروجی به حالت و جدایی آستانهٔ روشن و خاموش را توضیح می‌دهد. متن همان صفحه برای عبور از آستانه‌ها شرط‌های سخت و برای ورودی آغازینِ بین آستانه‌ها حالت خاموش را بیان می‌کند. قرارداد این مثال، تساوی را نیز تغییر حالت می‌داند و حالت اولیه را صریح می‌گیرد؛ برابری رفتار با بلوک Simulink ادعا نمی‌کنیم.

در یک ولتاژ میانی مانند ۲٫۵، خروجی می‌تواند روشن یا خاموش باشد؛ بستگی دارد آخرین نمونهٔ تعیین‌کننده از کدام سمت آمده باشد. از این ویژگی، حذف نویز، ایمنی یا کمینه‌شدن تعداد کلیدزنی را نتیجه نمی‌گیریم. اینجا فقط منطق گسستهٔ تعریف‌شده را بررسی می‌کنیم. پهنای ناحیه نیز یک انتخاب آموزشی است و از مشخصات واقعی مدار استخراج نشده است.

۲. چرا مثال تک‌نمونه‌ای کافی نیست؟

حفظ حالت قبلی در ناحیهٔ میانی
تصویر مفهومی: حفظ حالت قبلی در ناحیهٔ میانی؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

آزمونی که فقط ورودی ۲٫۵ را با حالت پیش‌فرض خاموش امتحان می‌کند، از دست رفتن حافظهٔ روشن را نمی‌بیند. باید ابتدا یک نمونهٔ سه‌ولتی بدهیم، بعد به ۲٫۵ برگردیم؛ خروجی باید روشن بماند. مسیر مقابل نیز لازم است: با حالت اولیهٔ روشن، نمونهٔ دوولتی خاموش می‌کند و مقدار میانی بعدی همچنان خاموش است. دو مسیر با مقدار انتهایی یکسان، پاسخ متفاوت دارند.

راهنمای آزمون حالت‌دار Hypothesis بر اهمیت دنبالهٔ اعمال و تعامل آن‌ها با حالت قبلی تأکید می‌کند. از این ایده برای طراحی بررسی استفاده می‌کنیم؛ در کد این درس Hypothesis اجرا نمی‌شود و تولید خودکار یا کوچک‌سازی خطا گزارش نمی‌کنیم. برنامه‌های کوتاه را خودمان با itertools می‌سازیم تا ترتیب داده‌ها و شمار مقایسه‌ها معلوم بماند.

ویژگی مناسب باید از قرارداد بیاید. حفظ حالت در ناحیهٔ بازِ میانی، یک ویژگی است؛ «برای هر ورودی فقط یک بار روشن شود» ویژگی درستی نیست، زیرا مسیر می‌تواند چند بار از دو آستانه بگذرد. اگر نمونهٔ تکراری را دوباره بدهیم، به‌تنهایی نباید گذار تازه‌ای بسازد. انتظار پاسخ ثابت برای یک مسیر صعودی نیز فقط با حالت آغاز و ترتیب مشخص معنا دارد.

۳. کد کامل با اعتبارسنجی پیش از محاسبه

اعتبارسنجی ورودی ولتاژ پیش از تغییر حالت
تصویر مفهومی: اعتبارسنجی ورودی ولتاژ پیش از تغییر حالت؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

تابع hysteresis فهرست یا tuple محدود می‌گیرد و حالت نهایی را کنار حالت پس از هر نمونه برمی‌گرداند. کل بسته پیش از محاسبه اعتبارسنجی می‌شود. فقط int و float دقیق، قابل تبدیل به float متناهی مجازند؛ bool با وجود رفتار عددی‌اش پذیرفته نمی‌شود. isfinite در پایتون برای رد NaN و بی‌نهایت به کار می‌رود. دادهٔ نامعتبر، معادل صفر ولت یا فقدان هشدار نیست.

"""Pure offline voltage hysteresis and independent prefix-history reference."""
import itertools
import json
import math
import platform


def _finite_number(value, field):
    if type(value) not in (int, float):
        raise TypeError(field + ' must be exact int or float')
    try:
        normalized = float(value)
    except OverflowError as error:
        raise ValueError(field + ' cannot be represented as finite float') from error
    if not math.isfinite(normalized):
        raise ValueError(field + ' must be finite')
    return normalized


def hysteresis(values, low=2.0, high=3.0, initial=False, unit='V'):
    """Enter ON at >= high, exit at <= low; retain state strictly between."""
    if type(unit) is not str or unit != 'V':
        raise ValueError('unit must be V')
    if type(initial) is not bool:
        raise TypeError('initial must be exact bool')
    low, high = _finite_number(low, 'low'), _finite_number(high, 'high')
    if not low < high:
        raise ValueError('low must be less than high after float conversion')
    if type(values) not in (list, tuple):
        raise TypeError('values must be a finite list or tuple')
    samples = tuple(_finite_number(value, 'sample') for value in values)
    # The entire finite batch is validated before any local transition/output.
    state = initial
    outputs = []
    for voltage in samples:
        if voltage >= high:
            state = True
        elif voltage <= low:
            state = False
        outputs.append(state)
    return {'states': outputs, 'final': state}


def reference_prefix(values, low, high, initial):
    """Full-prefix scan: the last decisive sample determines each output."""
    result = []
    for stop in range(1, len(values) + 1):
        decisive = [value for value in values[:stop] if value <= low or value >= high]
        result.append(decisive[-1] >= high if decisive else initial)
    return {'states': result, 'final': result[-1] if result else initial}


def _raises(expected, function, *args, **kwargs):
    try:
        function(*args, **kwargs)
    except expected:
        return True
    return False


def _changes(initial, states):
    return sum(a != b for a, b in zip([initial] + states, states))


def run_example():
    values = [1, 2, 2.5, 3, 2.75, 2, 2.25, 4, 1]
    expected = [False, False, False, True, True, False, False, True, False]
    main = hysteresis(values)
    alphabet = [-1.0, 2.0, 2.5, 3.0, 4.0]
    references = []
    for count in range(5):
        for program in itertools.product(alphabet, repeat=count):
            for initial in (False, True):
                references.append(hysteresis(program, initial=initial) ==
                                  reference_prefix(program, 2.0, 3.0, initial))
    splits = []
    for initial in (False, True):
        whole = hysteresis(values, initial=initial)
        for split in range(len(values) + 1):
            first = hysteresis(values[:split], initial=initial)
            last = hysteresis(values[split:], initial=first['final'])
            splits.append(first['states'] + last['states'] == whole['states']
                          and last['final'] == whole['final'])
    bad_thresholds = [(2, 2), (3, 2), (float('nan'), 3), (2, float('inf')),
                      (True, 3), (2, '3'), (2, complex(3)), (0, 2**10000),
                      (2**53, 2**53 + 1)]
    bad_samples = (True, float('nan'), float('inf'), -float('inf'), '2.5', None, 2j, 2**10000)
    unchanged = [1.0, 3.0, 2.5]
    saved = unchanged.copy()
    late_invalid = [1.0, float('nan')]
    checks = {
        'hand_nine_sample_states': main['states'] == expected,
        'hand_final_and_four_transitions': main['final'] is False and _changes(False, main['states']) == 4,
        'upper_equality_enters_on': all(hysteresis([3], initial=state)['states'] == [True]
                                       for state in (False, True)),
        'lower_equality_exits_off': all(hysteresis([2], initial=state)['states'] == [False]
                                       for state in (False, True)),
        'interior_retains_explicit_initial': all(
            hysteresis([2.5, 2.25, 2.75], initial=state)['states'] == [state] * 3
            for state in (False, True)),
        'upper_then_interior_remains_on': hysteresis([3, 2.5, 2.25])['states'] == [True] * 3,
        'lower_then_interior_remains_off': hysteresis([2, 2.5, 2.75], initial=True)['states'] == [False] * 3,
        'repeated_values_do_not_add_transitions': hysteresis([3, 3, 2.5, 2.5, 2, 2])['states'] ==
            [True, True, True, True, False, False],
        'monotonic_rise_one_change': hysteresis([-1, 2, 2.5, 3, 4])['states'] == [False, False, False, True, True],
        'monotonic_fall_one_change': hysteresis([4, 3, 2.5, 2, 1], initial=True)['states'] == [True, True, True, False, False],
        'all_chunk_splits_carry_state': all(splits),
        'independent_full_prefix_reference': len(references) == 1562 and all(references),
        'exact_fixture_translation_invariant': hysteresis([v + 1 for v in values], low=3, high=4) == main,
        'exact_fixture_positive_scale_invariant': hysteresis([2 * v for v in values], low=4, high=6) == main,
        'empty_batch_preserves_initial': all(hysteresis([], initial=state) == {'states': [], 'final': state}
                                             for state in (False, True)),
        'invalid_or_collapsed_thresholds_rejected': all(
            _raises((TypeError, ValueError), hysteresis, [], low, high) for low, high in bad_thresholds),
        'initial_type_and_unit_rejected': all(
            _raises(TypeError, hysteresis, [], initial=state) for state in (0, 1, 'off', None))
            and all(_raises(ValueError, hysteresis, [], unit=unit) for unit in ('mV', b'V', None)),
        'invalid_samples_and_container_no_input_mutation': all(
            _raises((TypeError, ValueError), hysteresis, [1.0, value]) for value in bad_samples)
            and all(_raises(TypeError, hysteresis, container) for container in ('123', iter([1]), None))
            and _raises(ValueError, hysteresis, late_invalid)
            and hysteresis(unchanged)['states'] == [False, True, True] and unchanged == saved,
    }
    assert len(checks) == 18 and all(type(value) is bool and value for value in checks.values())
    return {'runtime': {'python': platform.python_version(), 'libraries': ['stdlib only']},
            'contract': {'input_unit': 'V', 'low': 2.0, 'high': 3.0, 'initial': False,
                         'on_rule': 'voltage >= high', 'off_rule': 'voltage <= low',
                         'band_rule': 'low < voltage < high retains state',
                         'validation': 'entire finite batch before local transitions; no external state mutated'},
            'metrics': {'samples_V': values, 'states': main['states'], 'final': main['final'],
                        'transitions': _changes(False, main['states']),
                        'band_initial_off': hysteresis([2.5, 2.25, 2.75], initial=False)['states'],
                        'band_initial_on': hysteresis([2.5, 2.25, 2.75], initial=True)['states'],
                        'finite_reference_programs': len(references), 'chunk_split_comparisons': len(splits)},
            'checks': checks,
            'limitations': ['Finite deterministic property cases, no Hypothesis or Simulink execution.',
                            'Own inclusive boundaries and explicit initial state differ from Simulink Relay defaults/strict descriptions.',
                            'No time axis, debounce, duration threshold, physical relay, noise-removal or safety-control validation.',
                            'Decisions use represented finite floats; decimal quantization and sensor accuracy are not validated.']}


if __name__ == '__main__':
    print(json.dumps(run_example(), ensure_ascii=False, indent=2))

واحد دقیقاً V است؛ برچسب mV بدون تبدیل عدد پذیرفته نمی‌شود. آستانه‌ها نیز بعد از تبدیل به float باید جدا و مرتب بمانند. برای مثال، دو عدد صحیح بسیار نزدیک در اندازهٔ بزرگ ممکن است به یک float تبدیل شوند؛ برنامه برابریِ حاصل را رد می‌کند. این بررسی، بازیابی دقت ازدست‌رفته یا تعیین عدم قطعیت حسگر نیست. مقایسه‌ها روی مقدارهای نمایش‌یافته در float انجام می‌شوند.

حالت اولیه باید bool دقیق باشد؛ صفر و یکِ صحیح جای آن پذیرفته نمی‌شوند. تابع، شیء حالتِ بیرونی را تغییر نمی‌دهد و ورودی را بازنویسی نمی‌کند. بنابراین خطا در نمونهٔ دیرتر، حالت ماندگارِ نیمه‌به‌روزشده‌ای بیرون تابع باقی نمی‌گذارد. این رفتار مربوط به همین تابع خالص است؛ دربارهٔ تراکنش، دریافت شبکه یا پردازش زندهٔ بی‌نهایت ادعایی ندارد. اجرای ثبت‌شده با Python 3.14.5 انجام شده است.

۴. پاسخ دستی، مرجع مسیر و ویژگی بسته‌بندی

مرجع مسیر و آزمون مرزهای هیسترزیس
تصویر مفهومی: مرجع مسیر و آزمون مرزهای هیسترزیس؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

دنبالهٔ اصلی شامل یک، دو، ۲٫۵، سه، ۲٫۷۵، دو، ۲٫۲۵، چهار و یک ولت است. با حالت آغاز خاموش، سه خروجی اول خاموش‌اند؛ دو خروجی بعد روشن، دو خروجی بعد خاموش، سپس روشن و در پایان خاموش است. چهار تغییر حالت داریم. شمار گذار با مقایسهٔ حالت اولیه و تک‌تک حالت‌های بعدی محاسبه می‌شود؛ تعداد نمونه‌های روشن معیار دیگری است.

{
  "runtime": {
    "python": "3.14.5",
    "libraries": [
      "stdlib only"
    ]
  },
  "contract": {
    "input_unit": "V",
    "low": 2.0,
    "high": 3.0,
    "initial": false,
    "on_rule": "voltage >= high",
    "off_rule": "voltage <= low",
    "band_rule": "low < voltage < high retains state",
    "validation": "entire finite batch before local transitions; no external state mutated"
  },
  "metrics": {
    "samples_V": [
      1,
      2,
      2.5,
      3,
      2.75,
      2,
      2.25,
      4,
      1
    ],
    "states": [
      false,
      false,
      false,
      true,
      true,
      false,
      false,
      true,
      false
    ],
    "final": false,
    "transitions": 4,
    "band_initial_off": [
      false,
      false,
      false
    ],
    "band_initial_on": [
      true,
      true,
      true
    ],
    "finite_reference_programs": 1562,
    "chunk_split_comparisons": 20
  },
  "checks": {
    "hand_nine_sample_states": true,
    "hand_final_and_four_transitions": true,
    "upper_equality_enters_on": true,
    "lower_equality_exits_off": true,
    "interior_retains_explicit_initial": true,
    "upper_then_interior_remains_on": true,
    "lower_then_interior_remains_off": true,
    "repeated_values_do_not_add_transitions": true,
    "monotonic_rise_one_change": true,
    "monotonic_fall_one_change": true,
    "all_chunk_splits_carry_state": true,
    "independent_full_prefix_reference": true,
    "exact_fixture_translation_invariant": true,
    "exact_fixture_positive_scale_invariant": true,
    "empty_batch_preserves_initial": true,
    "invalid_or_collapsed_thresholds_rejected": true,
    "initial_type_and_unit_rejected": true,
    "invalid_samples_and_container_no_input_mutation": true
  },
  "limitations": [
    "Finite deterministic property cases, no Hypothesis or Simulink execution.",
    "Own inclusive boundaries and explicit initial state differ from Simulink Relay defaults/strict descriptions.",
    "No time axis, debounce, duration threshold, physical relay, noise-removal or safety-control validation.",
    "Decisions use represented finite floats; decimal quantization and sensor accuracy are not validated."
  ]
}

مرجع مستقل در هر پیشوند، آخرین نمونهٔ بیرون ناحیهٔ باز میانی را پیدا می‌کند. اگر چنین نمونه‌ای نباشد، حالت آغاز ملاک است. این روش، حلقهٔ تغییر حالت تابع اصلی را دوباره فراخوانی نمی‌کند. تمام دنباله‌های طول صفر تا چهار از پنج مقدار مشخص، با هر دو حالت اولیه مقایسه شده‌اند؛ ۱۵۶۲ مقایسه در یک گروه از هجده گروه بررسی قرار دارند، نه ۱۵۶۲ آزمون نام‌دار اضافی.

برای هر محل برش دنبالهٔ اصلی، قسمت اول را اجرا و حالت نهایی آن را به قسمت دوم می‌دهیم؛ ترکیب خروجی‌ها باید با اجرای یکجا برابر باشد. در مجموع بیست مقایسهٔ برش برای دو حالت اولیه داریم. اگر در آغاز هر بسته بی‌دلیل خاموش کنیم، ناحیهٔ میانی ممکن است پاسخ متفاوتی بدهد. بستهٔ خالی هم باید حالت ورودی خود را حفظ کند، نه اینکه آن را بازنشانی کند.

میانهٔ پنجرهٔ زمانی حسگر مقدارهای داخل پنجره را بررسی می‌کند؛ این درس تصمیم حالت‌دار است و پنجره یا زمان ندارد. برای ورودی نیز قرارداد خطای JSON حسگر مسیر مکملی است. هیچ‌کدام به‌تنهایی زمانِ پایداری هشدار، debounce یا اعتبار یک فرمان ایمنی را تأیید نمی‌کنند؛ فاصلهٔ زمانی نمونه‌ها در مثال حاضر اصلاً داده نشده است.

۵. چهار پرسش پاسخ‌دار برای انتخاب آزمون درست

انتخاب ویژگی‌های آزمون برای تصمیم حالت‌دار
تصویر مفهومی: انتخاب ویژگی‌های آزمون برای تصمیم حالت‌دار؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

پرسش اول: دقیقاً در سه و دو ولت چه می‌شود؟ پاسخ: سه ولت روشن و دو ولت خاموش می‌کند، مستقل از حالت قبلی. این قرارداد مساوی‌هاست و نباید با رفتار منبعی با شرط سخت یکی گرفته شود.

پرسش دوم: ورودی ۲٫۵ همیشه خاموش است؟ پاسخ: خیر؛ با حالت اولیهٔ روشن یا پس از رسیدن به آستانهٔ بالا، روشن می‌ماند. در ناحیهٔ میانی، حذف تاریخچه جواب را عوض می‌کند.

پرسش سوم: هنگام تقسیم داده‌ها کدام حالت را منتقل کنیم؟ پاسخ: final قسمت قبلی را، حتی اگر قسمت بعد با ولتاژ میانی شروع شود. بستهٔ خالی همان حالت را بازمی‌گرداند؛ بازنشانی سیاست دیگری است.

پرسش چهارم: هجده بررسی موفق یعنی هشدار واقعی امن است؟ پاسخ: خیر؛ مرز، حافظه و قرارداد همین تابع آزموده شده‌اند. زمان، نویز واقعی، مدار و ایمنی آزموده نشده‌اند. منابع در ۲۴ مهر ۱۴۰۵ خوانده شدند و نتیجه به دستگاه واقعی تعمیم داده نمی‌شود.

منابع و مطالعهٔ بیشتر

بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیل‌ها و پیشنهادهای اجرایی، جمع‌بندی نویسنده‌اند.