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

۱. مقدار دما را از اختلاف دما جدا کنید

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

برای یک مقدار دما، مبدأ مقیاس اهمیت دارد: ۲۵ درجهٔ سلسیوس برابر ۲۹۸٫۱۵ کلوین و ۷۷ درجهٔ فارنهایت است. اما افزایش ۲۵ درجهٔ سلسیوس، اختلافی برابر ۲۵ کلوین یا ۴۵ درجهٔ فارنهایت است. اختلافِ دو مقدار، فاصلهٔ جهت‌دار آن‌هاست؛ می‌تواند منفی باشد. مثلاً افت ۱۸ درجهٔ فارنهایت، اختلاف منفی ده کلوین است؛ این عدد منفی را نباید با دمای زیر صفر مطلق اشتباه گرفت.

تعریف و جدول دقیق تبدیل NIST، جابه‌جایی ۲۷۳٫۱۵ و نسبت دقیق ۱٫۸ را مشخص می‌کند. برای مقدار سلسیوس، جابه‌جایی وارد تبدیل کلوین می‌شود؛ برای اختلاف، همان جابه‌جایی حذف می‌شود. نام unit به‌تنهایی کافی نیست؛ در API، source و target واحد را مشخص می‌کنند و kind باید صریحاً point یا delta باشد.

تصمیم CGPM دربارهٔ کلوین یکای K را برای دما و اختلاف دما به کار می‌برد و بیان اختلاف با درجهٔ سلسیوس را نیز مجاز می‌داند. نماد کلوین علامت درجه ندارد. در کد، C و F صرفاً شناسه‌های محدودِ مقیاس‌اند؛ در متن علمی، نام و نماد کامل کمیت باید همراه عدد بماند.

۲. قرارداد گویا برای مرزهایی که باید دقیق باشند

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

تابع این درس float خام را عمداً نمی‌پذیرد. ورودی یا رشتهٔ اعشاری ASCII با حداکثر شش رقم صحیح و شش رقم اعشار است، یا شیء دقیق Fraction. فاصله، نمای علمی، علامت مثبت، صفرِ ابتداییِ اضافی و ارقام فارسی در این قالب پذیرفته نمی‌شوند. این محدودیت برای مرز روشن و تکرارپذیرِ مثال است؛ قانون عمومیِ نوشتن دما یا محدودیت کتابخانهٔ پایتون نیست.

برای Fraction ورودی، قدرمطلق صورت حداکثر ده به توان هجده و مخرج حداکثر ده به توان دوازده است. بنابراین دامنهٔ نمایش نیز کران دارد. اعداد صحیح معمولی، bool، رشتهٔ NaN و بی‌نهایت وارد محاسبه نمی‌شوند. اگر برنامهٔ بیرونی عدد float دارد، باید سیاست تبدیل آن را جدا و آگاهانه تعیین کند؛ تابع از املای اولیهٔ اعشاریِ عدد خبر ندارد و آن را بازسازی نمی‌کند.

طبق مستندات Fraction، ساخت مقدار از رشتهٔ اعشاری با ساخت آن از float یکسان نیست. ما ثابت ۲۷۳٫۱۵ را ۲۷۳۱۵ بر صد و نسبت تبدیل را پنج بر نه نگه می‌داریم. خروجی هم Fraction است؛ مثلاً اختلاف یک درجهٔ فارنهایت، دقیقاً پنج‌نهم درجهٔ سلسیوس است. چاپ اعشاریِ محدود می‌تواند تقریبی باشد، ولی آزمون به همان کسر دقیق متکی است.

۳. کد کامل؛ دو مسیر برای دو نوع کمیت

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

در مسیر point، مقدار ابتدا به کلوین تبدیل می‌شود و باید در بازهٔ بستهٔ صفر تا ده‌هزار کلوین باشد. این سقف انتخاب آموزشیِ ماست و مشخصات حسگر یا بیشینهٔ فیزیکی دما نیست. مدل معمول مهندسی این درس مقدار زیر صفر کلوین را رد می‌کند؛ نظریهٔ سامانه‌های دارای دمای آماری منفی در دامنهٔ آن نیست. رشتهٔ دقیق منفی ۴۵۹٫۶۷ فارنهایت در همین مدل، دقیقاً به صفر کلوین می‌رسد.

"""Original exact bounded point/difference temperature conversion lesson."""
import json
import platform
import re
from fractions import Fraction


UNITS = ('C', 'K', 'F')
ZERO_C = Fraction(27315, 100)
DECIMAL = re.compile(r'-?(?:0|[1-9][0-9]{0,5})(?:\.[0-9]{1,6})?\Z', re.ASCII)


def exact_value(value):
    if type(value) is str:
        if not DECIMAL.fullmatch(value):
            raise ValueError('expected bounded ASCII decimal without whitespace/exponent')
        return Fraction(value)
    if type(value) is Fraction:
        if abs(value.numerator) > 10**18 or value.denominator > 10**12:
            raise ValueError('rational representation outside authored bound')
        return value
    raise TypeError('value must be exact str or Fraction; floats deliberately rejected')


def convert(value, source, target, kind):
    if type(source) is not str or source not in UNITS or type(target) is not str or target not in UNITS:
        raise ValueError('units must be exact C/K/F codes')
    if type(kind) is not str or kind not in ('point', 'delta'):
        raise ValueError('kind must explicitly be point or delta')
    x = exact_value(value)
    if kind == 'point':
        kelvin = x if source == 'K' else x + ZERO_C if source == 'C' else (x - 32) * Fraction(5, 9) + ZERO_C
        if not 0 <= kelvin <= 10000:
            raise ValueError('point outside authored 0..10000 K interval')
        return kelvin if target == 'K' else kelvin - ZERO_C if target == 'C' else (kelvin - ZERO_C) * Fraction(9, 5) + 32
    kelvin_delta = x * Fraction(5, 9) if source == 'F' else x
    if abs(kelvin_delta) > 10000:
        raise ValueError('difference exceeds authored 10000 K magnitude')
    return kelvin_delta * Fraction(9, 5) if target == 'F' else kelvin_delta


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


def run_example():
    # Independent direct pairwise affine table; production converts through kelvin.
    pairwise = {
        ('C', 'C'): (1, 0), ('C', 'K'): (1, Fraction(27315, 100)), ('C', 'F'): (Fraction(9, 5), 32),
        ('K', 'C'): (1, Fraction(-27315, 100)), ('K', 'K'): (1, 0), ('K', 'F'): (Fraction(9, 5), Fraction(-45967, 100)),
        ('F', 'C'): (Fraction(5, 9), Fraction(-160, 9)), ('F', 'K'): (Fraction(5, 9), Fraction(45967, 180)), ('F', 'F'): (1, 0),
    }
    hand = [('25', '77', '298.15'), ('-40', '-40', '233.15'), ('0', '32', '273.15'), ('100', '212', '373.15')]
    hand_ok = all(convert(value, source, target, 'point') == Fraction(expected)
                  for c, f, k in hand for source, value in zip(('C', 'F', 'K'), (c, f, k))
                  for target, expected in zip(('C', 'F', 'K'), (c, f, k)))
    reference_cases = []
    for c in (Fraction(-200), Fraction(-1, 3), Fraction(0), Fraction(25), Fraction(12345, 100), Fraction(5000)):
        for source in UNITS:
            source_value = pairwise[('C', source)][0] * c + pairwise[('C', source)][1]
            for target in UNITS:
                slope, intercept = pairwise[(source, target)]
                reference_cases.append(convert(source_value, source, target, 'point') == slope * source_value + intercept)
    roundtrips = [convert(convert(value, source, target, 'point'), target, source, 'point') == exact_value(value)
                  for source in UNITS for target in UNITS for value in ('1', '20', '100')]
    low_bounds = {'C': '-273.15', 'K': '0', 'F': '-459.67'}
    high_bounds = {'C': '9726.85', 'K': '10000', 'F': '17540.33'}
    invalid_text = ('NaN', 'Infinity', '-inf', '1e3', ' 25', '25 ', '+25', '01', '.5', '1.', '1.1234567', '۲۵', '1000000')
    point_delta_gap = convert('25', 'C', 'F', 'point') - convert('25', 'C', 'F', 'delta')
    checks = {
        'hand_point_table_all_nine_directions': hand_ok,
        'exact_absolute_zero_three_input_units': all(convert(v, u, 'K', 'point') == 0 for u, v in low_bounds.items()),
        'exact_upper_point_bound_three_input_units': all(convert(v, u, 'K', 'point') == 10000 for u, v in high_bounds.items()),
        'point_below_absolute_zero_rejected': all(_raises(ValueError, convert, Fraction(v) - Fraction(1, 10**6), u, 'K', 'point') for u, v in low_bounds.items()),
        'point_above_contract_upper_bound_rejected': all(_raises(ValueError, convert, Fraction(v) + Fraction(1, 10**6), u, 'K', 'point') for u, v in high_bounds.items()),
        'fahrenheit_difference_eighteen_is_ten_kelvin': convert('18', 'F', 'K', 'delta') == convert('10', 'C', 'K', 'delta') == 10,
        'negative_difference_has_sign_and_no_absolute_zero_floor': convert('-18', 'F', 'C', 'delta') == -10,
        'zero_difference_remains_zero_every_direction': all(convert('0', s, t, 'delta') == 0 for s in UNITS for t in UNITS),
        'point_and_difference_offset_are_not_interchangeable': point_delta_gap == 32,
        'difference_of_converted_points_matches_converted_difference': convert('35', 'C', 'F', 'point') - convert('25', 'C', 'F', 'point') == convert('10', 'C', 'F', 'delta') == 18,
        'difference_magnitude_bound_includes_signed_endpoints': convert('-18000', 'F', 'K', 'delta') == -10000 and convert('10000', 'C', 'F', 'delta') == 18000
            and _raises(ValueError, convert, '18000.000001', 'F', 'K', 'delta'),
        'independent_direct_pairwise_reference_fifty_four_cases': len(reference_cases) == 54 and all(reference_cases),
        'exact_fraction_output_roundtrip_twenty_seven_cases': len(roundtrips) == 27 and all(roundtrips),
        'representation_is_rational_not_float_approximation': convert('1', 'F', 'C', 'delta') == Fraction(5, 9) and type(convert('1', 'F', 'C', 'delta')) is Fraction,
        'float_bool_int_none_and_container_rejected': all(_raises(TypeError, convert, x, 'C', 'K', 'point') for x in (25.0, True, 25, None, ['25'])),
        'nonfinite_ambiguous_or_oversized_decimal_text_rejected': all(_raises(ValueError, convert, x, 'C', 'K', 'point') for x in invalid_text),
        'unit_and_quantity_kind_must_be_explicit_exact_codes': all(_raises(ValueError, convert, '25', u, 'K', 'point') for u in ('c', '°C', 'degC', None, True))
            and _raises(ValueError, convert, '25', 'C', 'K', 'absolute') and _raises(ValueError, convert, '25', 'C', 'kelvin', 'point'),
        'rational_representation_limits_checked_before_arithmetic': _raises(ValueError, convert, Fraction(1, 10**12 + 1), 'K', 'K', 'point')
            and _raises(ValueError, convert, Fraction(10**18 + 1), 'C', 'C', 'delta')
            and exact_value(Fraction(1, 10**12)) == Fraction(1, 10**12),
    }
    assert len(checks) == 18 and all(type(v) is bool and v for v in checks.values())
    return {'runtime': {'python': platform.python_version(), 'arithmetic': 'stdlib fractions.Fraction; no hardware or calibration'},
            'contract': {'point_kelvin_inclusive': [0, 10000], 'delta_kelvin_absolute_max_inclusive': 10000,
                         'input': 'exact ASCII decimal string <=6 integer and <=6 fractional digits, or exact Fraction with abs(numerator)<=1e18/denominator<=1e12; no float/bool/int coercion',
                         'output': 'exact Fraction; unit/point-vs-delta explicit'},
            'metrics': {'25_C_point_in_K': str(convert('25', 'C', 'K', 'point')),
                        '25_C_point_in_F': str(convert('25', 'C', 'F', 'point')),
                        '25_C_delta_in_F': str(convert('25', 'C', 'F', 'delta')),
                        '18_F_delta_in_K': str(convert('18', 'F', 'K', 'delta')),
                        'one_F_delta_in_C': str(convert('1', 'F', 'C', 'delta')),
                        'absolute_zero_F_in_K': str(convert('-459.67', 'F', 'K', 'point')),
                        'independent_pairwise_reference_cases': len(reference_cases), 'exact_roundtrip_cases': len(roundtrips)},
            'checks': checks,
            'limitations': ['Authored bounded decimal/rational input contract, not a universal unit parser; raw float deliberately rejected.',
                            'Exact arithmetic applies to represented rational values and fixed scale definitions, not physical sensing accuracy or uncertainty.',
                            'Point below 0 K excluded from this ordinary engineering model; no claim to model exotic statistical negative-temperature systems.',
                            'No thermodynamic process, ITS-90 realization, calibration, database, safety controller or network is implemented.']}


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

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

نوع خروجی از نوع کمیت جداست: Fraction فقط عدد را نمایش می‌دهد و خودش «دمای نقطه‌ای» یا «اختلاف دما» را حمل نمی‌کند. فراخواننده باید واحد و kind را همراه نتیجه نگه دارد. همچنین تضمین نمی‌کنیم هر خروجیِ کسری با هر مخرجی دوباره از کران نمایش ورودی بگذرد؛ آزمون رفت‌وبرگشت برای fixtureهای اعلام‌شده است، نه ادعای بسته‌بودن تمام فضای ورودی زیر هر ترکیب تبدیل.

۴. آزمون مستقل و پاسخ‌هایی که خطای offset را می‌گیرند

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

کد با Python 3.14.5 اجرا شده است. پاسخ دستی ۲۵ سلسیوس برای point برابر ۷۷ فارنهایت و برای delta برابر ۴۵ فارنهایت شد؛ فاصلهٔ آن دو، ۳۲ است. با کم‌کردن مقدارهای تبدیل‌شدهٔ ۳۵ و ۲۵ سلسیوس، اختلاف ۱۸ فارنهایت می‌گیریم؛ تبدیل مستقیم اختلاف ده سلسیوس نیز همان نتیجه را دارد. این آزمون، اضافه‌کردن اشتباهِ offset به اختلاف را آشکار می‌کند.

{
  "runtime": {
    "python": "3.14.5",
    "arithmetic": "stdlib fractions.Fraction; no hardware or calibration"
  },
  "contract": {
    "point_kelvin_inclusive": [
      0,
      10000
    ],
    "delta_kelvin_absolute_max_inclusive": 10000,
    "input": "exact ASCII decimal string <=6 integer and <=6 fractional digits, or exact Fraction with abs(numerator)<=1e18/denominator<=1e12; no float/bool/int coercion",
    "output": "exact Fraction; unit/point-vs-delta explicit"
  },
  "metrics": {
    "25_C_point_in_K": "5963/20",
    "25_C_point_in_F": "77",
    "25_C_delta_in_F": "45",
    "18_F_delta_in_K": "10",
    "one_F_delta_in_C": "5/9",
    "absolute_zero_F_in_K": "0",
    "independent_pairwise_reference_cases": 54,
    "exact_roundtrip_cases": 27
  },
  "checks": {
    "hand_point_table_all_nine_directions": true,
    "exact_absolute_zero_three_input_units": true,
    "exact_upper_point_bound_three_input_units": true,
    "point_below_absolute_zero_rejected": true,
    "point_above_contract_upper_bound_rejected": true,
    "fahrenheit_difference_eighteen_is_ten_kelvin": true,
    "negative_difference_has_sign_and_no_absolute_zero_floor": true,
    "zero_difference_remains_zero_every_direction": true,
    "point_and_difference_offset_are_not_interchangeable": true,
    "difference_of_converted_points_matches_converted_difference": true,
    "difference_magnitude_bound_includes_signed_endpoints": true,
    "independent_direct_pairwise_reference_fifty_four_cases": true,
    "exact_fraction_output_roundtrip_twenty_seven_cases": true,
    "representation_is_rational_not_float_approximation": true,
    "float_bool_int_none_and_container_rejected": true,
    "nonfinite_ambiguous_or_oversized_decimal_text_rejected": true,
    "unit_and_quantity_kind_must_be_explicit_exact_codes": true,
    "rational_representation_limits_checked_before_arithmetic": true
  },
  "limitations": [
    "Authored bounded decimal/rational input contract, not a universal unit parser; raw float deliberately rejected.",
    "Exact arithmetic applies to represented rational values and fixed scale definitions, not physical sensing accuracy or uncertainty.",
    "Point below 0 K excluded from this ordinary engineering model; no claim to model exotic statistical negative-temperature systems.",
    "No thermodynamic process, ITS-90 realization, calibration, database, safety controller or network is implemented."
  ]
}

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

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

آزمون درون‌یابی کالیبراسیون دربارهٔ نگاشت نمونه‌ها پرسش دیگری دارد و آزمون دسی‌بل و مرجع کمیت نیز نمونهٔ متفاوتی از لزوم برچسب درست است. منابع در ۲۴ مهر ۱۴۰۵ خوانده شدند؛ صفحهٔ دمای NIST در ۱۷ ژوئن ۲۰۲۶ به‌روزرسانی شده و مستندات پایتون نسخهٔ 3.14.8 دارند. این تاریخ‌ها از اجرای کد جدا ثبت شده‌اند.

۵. چهار پرسش با پاسخ کامل

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

پرسش اول: اختلاف ۲۵ سلسیوس را ۷۷ فارنهایت ثبت کنیم؟ پاسخ: خیر؛ ۷۷ مربوط به مقدار ۲۵ سلسیوس است. اختلاف ۲۵ سلسیوس برابر ۴۵ فارنهایت است و offset به آن اضافه نمی‌شود.

پرسش دوم: اختلاف منفی ده کلوین نامعتبر است؟ پاسخ: در قرارداد delta معتبر است و کاهش را بیان می‌کند؛ در قرارداد point، مقدار منفی ده کلوین رد می‌شود. نوع کمیت تعیین‌کننده است.

پرسش سوم: چرا float منفی ۴۵۹٫۶۷ مستقیم پذیرفته نیست؟ پاسخ: نمایش دودویی لزوماً همان کسر اعشاریِ مرزی نیست. ورودی این API رشته یا Fraction دقیق است؛ سیاست پذیرش float باید جدا طراحی و آزموده شود.

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

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

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