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

۱. نیازمندی کوچک با جدول و واحد مشخص بسازیم

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

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

راهنمای interp در NumPy 2.4 درون‌یابی خطی تکه‌ای را توضیح می‌دهد و افزایش مختصات ورودی را انتظار دارد، اما آن ترتیب را خودکار و کامل کنترل نمی‌کند. تابع آموزشی ما کنترل صریح دارد؛ این رفتار سخت‌گیرانه را به NumPy نسبت نمی‌دهیم. NumPy و SciPy در کد اجرا نمی‌شوند و منبع، تعریف و سیاست رابط را روشن می‌کند.

جدول‌های ورودی فقط list یا tuple دقیق، با طول برابر و حداقل دو عضو هستند. اعداد باید int یا float دقیق و متناهی باشند؛ bool، رشته و مختلط رد می‌شوند. واحد ورودی V و خروجی mA است و نوع و متن برچسب واحد بررسی می‌شود. این برچسب ثابت، اشتباه پنهانِ معنای یک عدد را کشف نمی‌کند؛ فراخواننده باید واقعاً ولت بدهد، نه اینکه مقدار میلی‌ولت را با نام ولت وارد کند.

۲. دو همسایه، وزن بی‌بُعد و مرجع دستی

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

برای پرسش q بین x0 و x1، وزن را از t=(q-x0)/(x1-x0) می‌سازیم. خروجی (1-t)*y0+t*y1 است. چون صورت و مخرج وزن هر دو ولت‌اند، t بی‌بُعد و خروجی همچنان میلی‌آمپر است. وقتی وزن بین صفر و یک باشد، مقدار ریاضی بین دو خروجی محلی قرار می‌گیرد؛ این گزاره به معنی افزایشی بودن تمام حسگرها نیست.

برای ۱٫۷۵ ولت، دو همسایه یک و ۲٫۵ هستند؛ فاصله از همسایهٔ اول ۰٫۷۵ و عرض بازه ۱٫۵ است، پس t برابر نیم می‌شود. میان هشت و یازده، پاسخ ۹٫۵ میلی‌آمپر است. برای ۰٫۵، پاسخ شش و برای ۳٫۲۵، پاسخ ۱۵٫۵ است. این سه جواب پیش از اجرا با حساب دستی معلوم‌اند و از خروجی تابع برای ساخت مرجع استفاده نشده است.

با bisect_right پایتون، محل درج در سمت راست مقدارهای برابر را پیدا و یک واحد کم می‌کنیم. سپس برابری دقیق با گره را جدا می‌سنجیم. روی آخرین گره، همین شاخه مانع دسترسی به همسایهٔ ناموجود می‌شود. مقدار گره را پس از تبدیل کنترل‌شده به float مستقیم برمی‌گردانیم؛ این قرارداد با «محاسبهٔ دوباره و تقریباً برابر شدن» فرق دارد.

۳. بیرون دامنه را بی‌صدا معتبر جلوه ندهیم

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

در تابع ما دو انتهای بازه بسته‌اند؛ صفر و چهار ولت پذیرفته می‌شوند، ولی منفی ۰٫۰۰۱ و ۴٫۰۰۱ خطا هستند. راهنمای extrapolation در SciPy توضیح می‌دهد که پیش‌فرض NumPy مقدار انتهای جدول را بیرون دامنه ثابت نگه می‌دارد. این «امتداد ثابت» با امتداد خطی متفاوت است؛ ما هیچ‌کدام را در این قرارداد انجام نمی‌دهیم و رفتار کتابخانه‌ها را یکسان فرض نمی‌کنیم.

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

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

۴. کد کامل و هجده بررسی با مرجع کسری

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

فایل calibration_interpolation.py با Python 3.14.5 و کتابخانهٔ استاندارد اجرا شده است. مرجع دوم با Fraction پایتون و پیمایش خطی بازه‌ها محاسبه می‌کند؛ مسیر اصلی bisect و حساب float دارد. در مرجع، رشتهٔ عددهای تألیفیِ نیم و ربع را به کسر تبدیل می‌کنیم. این روش برای همین دادهٔ دقیق انتخاب شده؛ تبدیل هر float دلخواه به رشته، مقدار اولیهٔ اندازه‌گیری را بازیابی نمی‌کند.

"""Piecewise-linear synthetic voltage/current table; no physical calibration."""
import bisect
import copy
from fractions import Fraction
import json
import math
import platform


def finite_real(value):
    if type(value) not in (int, float):
        raise TypeError('Exact int or float required; bool, string and complex excluded')
    try:
        result = float(value)
    except OverflowError as error:
        raise ValueError('Value must fit a finite float') from error
    if not math.isfinite(result):
        raise ValueError('Finite real value required')
    return result


def interpolate_calibration(voltage_v, current_ma, query_v, input_unit='V', output_unit='mA'):
    if type(input_unit) is not str or input_unit != 'V' or type(output_unit) is not str or output_unit != 'mA':
        raise ValueError('This profile requires exact V input and mA output units')
    if type(voltage_v) not in (list, tuple) or type(current_ma) not in (list, tuple):
        raise TypeError('Exact list or tuple tables required')
    if len(voltage_v) < 2 or len(voltage_v) != len(current_ma):
        raise ValueError('At least two matching table entries required')
    xs = [finite_real(x) for x in voltage_v]
    ys = [finite_real(y) for y in current_ma]
    q = finite_real(query_v)
    for left, right in zip(xs, xs[1:]):
        width = right - left
        if not math.isfinite(width) or width <= 0:
            raise ValueError('Converted voltage knots need finite positive spacings')
    if q < xs[0] or q > xs[-1]:
        raise ValueError('No clamping or extrapolation in this profile')
    i = bisect.bisect_right(xs, q) - 1
    if q == xs[i]:
        return {'query_v': q, 'current_ma': ys[i], 'bracket_indices': (i, i), 'fraction': 0.0}
    t = (q - xs[i]) / (xs[i+1] - xs[i])
    if not math.isfinite(t) or not 0 <= t <= 1:
        raise ArithmeticError('Finite interpolation weight in [0,1] required')
    try:
        value = math.fsum(((1.0-t) * ys[i], t * ys[i+1]))
    except OverflowError as error:
        raise ArithmeticError('Finite interpolated result required') from error
    if not math.isfinite(value):
        raise ArithmeticError('Finite interpolated result required')
    return {'query_v': q, 'current_ma': value, 'bracket_indices': (i, i+1), 'fraction': t}


def rational_oracle(xs, ys, q):
    """Linear scan and exact rational fixture values, no bisect or float arithmetic."""
    xr = [Fraction(str(x)) for x in xs]
    yr = [Fraction(str(y)) for y in ys]
    qr = Fraction(str(q))
    for x, y in zip(xr, yr):
        if qr == x:
            return y
    for i in range(len(xr)-1):
        if xr[i] < qr < xr[i+1]:
            return yr[i] + (qr-xr[i]) * (yr[i+1]-yr[i]) / (xr[i+1]-xr[i])
    raise ValueError('Oracle query outside fixture domain')


def near(a, b):
    return math.isclose(a, b, rel_tol=0, abs_tol=1e-12)


def rejects(call):
    try:
        call()
    except (TypeError, ValueError, ArithmeticError):
        return True
    return False


def run_example():
    xs, ys = [0.0, 1.0, 2.5, 4.0], [4.0, 8.0, 11.0, 20.0]
    queries = [0.0, 0.5, 1.0, 1.75, 2.5, 3.25, 4.0]
    hand = [4.0, 6.0, 8.0, 9.5, 11.0, 15.5, 20.0]
    original = copy.deepcopy((xs, ys))
    results = [interpolate_calibration(xs, ys, q) for q in queries]
    grid = [i/4 for i in range(17)]
    dense = [interpolate_calibration(xs, ys, q) for q in grid]
    middle = interpolate_calibration(xs, ys, 1.75)
    bad_numbers = [True, '1', 1+0j, math.nan, math.inf, -math.inf, 10**400]
    checks = {
        'seven_hand_values_match_with_declared_absolute_tolerance': all(near(r['current_ma'], y) for r, y in zip(results, hand)),
        'every_exact_knot_returns_stored_current_and_degenerate_bracket': all(
            interpolate_calibration(xs, ys, x)['current_ma'] == y
            and interpolate_calibration(xs, ys, x)['bracket_indices'] == (i, i)
            for i, (x, y) in enumerate(zip(xs, ys))),
        'hand_middle_bracket_and_half_weight_are_explicit': middle['bracket_indices'] == (1, 2)
            and middle['fraction'] == 0.5 and middle['current_ma'] == 9.5,
        'seventeen_quarter_volt_points_match_independent_fraction_scan': all(
            near(r['current_ma'], float(rational_oracle(xs, ys, q))) for r, q in zip(dense, grid)),
        'each_tested_intermediate_value_stays_between_local_endpoints': all(
            min(ys[r['bracket_indices'][0]], ys[r['bracket_indices'][1]]) <= r['current_ma']
            <= max(ys[r['bracket_indices'][0]], ys[r['bracket_indices'][1]]) for r in dense),
        'known_affine_function_reproduced_on_all_grid_points': all(
            near(interpolate_calibration(xs, [3*x-2 for x in xs], q)['current_ma'], 3*q-2) for q in grid),
        'current_values_need_not_be_monotonic_for_forward_interpolation': interpolate_calibration([0, 1, 2], [2, 1, 3], 0.5)['current_ma'] == 1.5
            and interpolate_calibration([0, 1, 2], [2, 1, 3], 1.5)['current_ma'] == 2.0,
        'constant_current_table_stays_constant': all(interpolate_calibration(xs, [7]*4, q)['current_ma'] == 7 for q in grid),
        'both_outside_domain_sides_are_rejected_not_clamped': rejects(lambda: interpolate_calibration(xs, ys, -0.001))
            and rejects(lambda: interpolate_calibration(xs, ys, 4.001)),
        'one_representable_step_outside_each_endpoint_is_rejected': rejects(lambda: interpolate_calibration(xs, ys, math.nextafter(0.0, -math.inf)))
            and rejects(lambda: interpolate_calibration(xs, ys, math.nextafter(4.0, math.inf))),
        'millivolts_require_explicit_numeric_conversion_before_V_profile': rejects(lambda: interpolate_calibration(xs, ys, 1750, input_unit='mV'))
            and interpolate_calibration(xs, ys, 1750/1000)['current_ma'] == 9.5,
        'wrong_output_or_input_unit_strings_and_types_rejected': all(
            rejects(lambda u=u: interpolate_calibration(xs, ys, 1, input_unit=u)) for u in ('v', '', None, 1))
            and rejects(lambda: interpolate_calibration(xs, ys, 1, output_unit='A')),
        'unsorted_and_duplicate_voltage_knots_rejected': rejects(lambda: interpolate_calibration([0, 2, 1], [4, 8, 11], 1))
            and rejects(lambda: interpolate_calibration([0, 1, 1], [4, 8, 11], 1)),
        'float_conversion_collapsed_knots_and_overflowed_spacing_rejected': rejects(
            lambda: interpolate_calibration([2**53, 2**53+1], [4, 8], 2**53))
            and rejects(lambda: interpolate_calibration([-1e308, 1e308], [4, 8], 0)),
        'short_mismatched_and_wrong_container_tables_rejected': all(rejects(call) for call in (
            lambda: interpolate_calibration([], [], 0), lambda: interpolate_calibration([0], [4], 0),
            lambda: interpolate_calibration([0, 1], [4], 0), lambda: interpolate_calibration('01', [4, 8], 0))),
        'nonfinite_and_wrong_scalar_types_rejected_in_all_numeric_roles': all(
            rejects(lambda value=v: interpolate_calibration(xs, ys, value))
            and rejects(lambda value=v: interpolate_calibration([0, value], [4, 8], 0))
            and rejects(lambda value=v: interpolate_calibration([0, 1], [4, value], 0)) for v in bad_numbers),
        'input_tables_unchanged_and_tuple_tables_agree': (xs, ys) == original
            and interpolate_calibration(tuple(xs), tuple(ys), 1.75) == middle,
        'adjacent_piece_slopes_are_different_not_one_global_line': near(
            (interpolate_calibration(xs, ys, 1)['current_ma'] - interpolate_calibration(xs, ys, 0.75)['current_ma'])/0.25, 4.0)
            and near((interpolate_calibration(xs, ys, 1.25)['current_ma'] - interpolate_calibration(xs, ys, 1)['current_ma'])/0.25, 2.0),
    }
    assert len(checks) == 18 and all(type(v) is bool and v for v in checks.values()), checks
    return {'python': platform.python_version(), 'dependencies': 'standard library only',
            'scope': 'Authored voltage/current table only; no real sensor, traceability, uncertainty estimate, NumPy or SciPy execution',
            'units': {'voltage': 'V', 'current': 'mA'}, 'voltage_knots_v': xs, 'current_knots_ma': ys,
            'queries_v': queries, 'hand_reference_ma': hand, 'observed': results,
            'absolute_fixture_tolerance_ma': 1e-12,
            'oracle_quarter_volt_queries': len(grid), 'outside_domain_policy': 'raise, no clamp or extrapolation',
            'checks': checks}


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

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

{
  "python": "3.14.5",
  "dependencies": "standard library only",
  "scope": "Authored voltage/current table only; no real sensor, traceability, uncertainty estimate, NumPy or SciPy execution",
  "units": {
    "voltage": "V",
    "current": "mA"
  },
  "voltage_knots_v": [
    0.0,
    1.0,
    2.5,
    4.0
  ],
  "current_knots_ma": [
    4.0,
    8.0,
    11.0,
    20.0
  ],
  "queries_v": [
    0.0,
    0.5,
    1.0,
    1.75,
    2.5,
    3.25,
    4.0
  ],
  "hand_reference_ma": [
    4.0,
    6.0,
    8.0,
    9.5,
    11.0,
    15.5,
    20.0
  ],
  "observed": [
    {
      "query_v": 0.0,
      "current_ma": 4.0,
      "bracket_indices": [
        0,
        0
      ],
      "fraction": 0.0
    },
    {
      "query_v": 0.5,
      "current_ma": 6.0,
      "bracket_indices": [
        0,
        1
      ],
      "fraction": 0.5
    },
    {
      "query_v": 1.0,
      "current_ma": 8.0,
      "bracket_indices": [
        1,
        1
      ],
      "fraction": 0.0
    },
    {
      "query_v": 1.75,
      "current_ma": 9.5,
      "bracket_indices": [
        1,
        2
      ],
      "fraction": 0.5
    },
    {
      "query_v": 2.5,
      "current_ma": 11.0,
      "bracket_indices": [
        2,
        2
      ],
      "fraction": 0.0
    },
    {
      "query_v": 3.25,
      "current_ma": 15.5,
      "bracket_indices": [
        2,
        3
      ],
      "fraction": 0.5
    },
    {
      "query_v": 4.0,
      "current_ma": 20.0,
      "bracket_indices": [
        3,
        3
      ],
      "fraction": 0.0
    }
  ],
  "absolute_fixture_tolerance_ma": 1e-12,
  "oracle_quarter_volt_queries": 17,
  "outside_domain_policy": "raise, no clamp or extrapolation",
  "checks": {
    "seven_hand_values_match_with_declared_absolute_tolerance": true,
    "every_exact_knot_returns_stored_current_and_degenerate_bracket": true,
    "hand_middle_bracket_and_half_weight_are_explicit": true,
    "seventeen_quarter_volt_points_match_independent_fraction_scan": true,
    "each_tested_intermediate_value_stays_between_local_endpoints": true,
    "known_affine_function_reproduced_on_all_grid_points": true,
    "current_values_need_not_be_monotonic_for_forward_interpolation": true,
    "constant_current_table_stays_constant": true,
    "both_outside_domain_sides_are_rejected_not_clamped": true,
    "one_representable_step_outside_each_endpoint_is_rejected": true,
    "millivolts_require_explicit_numeric_conversion_before_V_profile": true,
    "wrong_output_or_input_unit_strings_and_types_rejected": true,
    "unsorted_and_duplicate_voltage_knots_rejected": true,
    "float_conversion_collapsed_knots_and_overflowed_spacing_rejected": true,
    "short_mismatched_and_wrong_container_tables_rejected": true,
    "nonfinite_and_wrong_scalar_types_rejected_in_all_numeric_roles": true,
    "input_tables_unchanged_and_tuple_tables_agree": true,
    "adjacent_piece_slopes_are_different_not_one_global_line": true
  }
}

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

۵. چهار تمرین پاسخ‌دار و تحویل قابل بازبینی

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

تمرین اول: چرا ۱٫۷۵ ولت، ۹٫۵ میلی‌آمپر می‌دهد؟ پاسخ: میان یک و ۲٫۵ دقیقاً نیمه است؛ پس وزن دو خروجی هشت و یازده برابر و میانگین آن‌ها ۹٫۵ می‌شود. همسایهٔ صفر یا چهار در این محاسبه دخالت ندارد.

تمرین دوم: با ۱۷۵۰ میلی‌ولت چه کنیم؟ پاسخ: پیش از ورود به رابط V، صریح بر هزار تقسیم کنیم تا ۱٫۷۵ ولت شود. ارسال برچسب mV به تابع رد می‌شود. خروجی mA نیز بدون تبدیل عددی، آمپر نامیده نمی‌شود.

تمرین سوم: آیا ۴٫۰۰۱ ولت را می‌توان بی‌صدا بیست برگرداند؟ پاسخ: در این قرارداد خیر؛ حتی یک گام نمایش‌پذیر بیرون مرز خطاست. برای سیاست اشباع یا امتداد، نیازمندی و مرجع تازه لازم است؛ حذف استثنا به تنهایی اصلاح معتبر نیست.

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

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

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