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

۱. قرارداد ستون‌ها را پیش از تبدیل به دیکشنری بنویسیم

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

قرارداد ما پنج نام دقیق دارد: sample_id, time_ms, voltage_mv, unit, note. حضور همهٔ آن‌ها لازم است و ستون اضافه نداریم. ترتیب ستون‌ها می‌تواند تغییر کند، چون بعد از بررسی، با نام به مقدار دسترسی داریم. بزرگی حروف و فاصله بخشی از نام‌اند؛ برنامه نام نادرست را حدس نمی‌زند و فاصلهٔ ابتدا را خودکار حذف نمی‌کند.

پیش از ساخت دیکشنری، تکراری نبودن نام‌ها را کنترل می‌کنیم. دو ستون هم‌نام، دو دادهٔ مستقل هستند و یک کلید نمی‌تواند هر دو را نمایندگی کند. در مستند reader و DictReader پایتون، خواندن قالب با تبدیل به نگاشت توضیح داده شده است؛ اما قواعد دامنهٔ ما را باید جدا نوشت. اینجا reader را انتخاب می‌کنیم تا تعداد و نام ستون‌ها پیش از نگاشت دیده شوند.

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

۲. ویرگول و پایان سطر همیشه مرز داده نیستند

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

در بخش دوم RFC 4180، نقل‌قول برای فیلد حاوی ویرگول یا شکست خط و دوبرابر کردن علامت نقل‌قول داخلی شرح داده می‌شود. این سند تاریخیِ اکتبر ۲۰۰۵ از نوع Informational است. الزام سرستون، یکتایی شناسه و واحد mV، انتخاب قرارداد ما هستند؛ آن‌ها را الزام عمومی هر فایل CSV معرفی نمی‌کنیم.

یادداشت ردیف نخست probe, left است. ویرگول داخل نقل‌قول، ستون ششم نمی‌سازد. یادداشت دوم پس از خواندن، عبارت quoted "check" می‌شود. یک بررسی دیگر، شکست خط داخل یادداشت نقل‌قول‌شده را نگه می‌دارد و همچنان یک رکورد برمی‌گرداند. پس split کردن ساده روی ویرگول یا سطر، جای خوانندهٔ CSV را نمی‌گیرد.

reader روی StringIO با newline خالی و جداکنندهٔ صریح ویرگول اجرا می‌شود. LF، CRLF و نبود شکست خط پایانی در نمونه‌های آزمون پذیرفته می‌شوند؛ رکورد خالی میان داده‌ها رد می‌شود. strict خطاهای نحویِ آزموده‌شده، مانند نقل‌قول ناتمام، را آشکار می‌کند؛ اعتبارسنج کامل همهٔ قواعد RFC یا معنای فیلد نیست. تعداد سطرهای فیزیکی نیز با شمار رکوردهای منطقیِ دارای یادداشت چندخطی برابر نیست.

۳. عدد اعشاری متن را با واحد روشن تبدیل کنیم

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

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

به جای float، بخش صحیح را در هزار ضرب و بخش اعشار را از راست تا سه رقم با صفر کامل می‌کنیم. ورودی ۱۲۵٫۲۵۰ میلی‌ولت، به‌صورت متن لاتین در فایل، دقیقاً ۱۲۵۲۵۰ میکروولت صحیح می‌شود. منفی ۰٫۱۲۵ به منفی ۱۲۵ و صفر به صفر می‌رسد. این تبدیلِ نمایش است؛ حسگر را دقیق‌تر نمی‌کند و کالیبراسیون، خطای اندازه‌گیری یا تفکیک‌پذیری سخت‌افزار را تعیین نمی‌کند.

برچسب واحد باید دقیقاً mV باشد؛ V یا mv بی‌صدا معادل آن نمی‌شود. بازهٔ پذیرفته‌شده را منفی ۵۰۰۰ تا مثبت ۵۰۰۰ میلی‌ولت، با دو انتهای بسته انتخاب کرده‌ایم. مقدار ۵۰۰۰٫۰۰۱ یک میکروولت بیرون از مرز است و رد می‌شود. این بازه فرض آموزشی است؛ برای دستگاه واقعی باید از مشخصات و مسیر تبدیل معتبر آن استفاده کرد. گردکردن مقدار بیرون از قرارداد، در این تابع انجام نمی‌شود.

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

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

فایل sensor_csv_acceptance.py را با python sensor_csv_acceptance.py اجرا کنید. اجرای ثبت‌شده با Python 3.14.5 و کتابخانهٔ استاندارد است. ورودی تابع، متنِ از قبل رمزگشایی‌شده است؛ فایل یا بایت را مستقیم نمی‌گیرد. انتخاب encoding و مدیریت خواندن فایل، مرحلهٔ بیرونی است و در این آزمایش اجرا نمی‌شود.

"""Original small CSV acceptance fixture; no file, sensor or spreadsheet execution."""
import csv
import io
import json
import platform
import re

HEADER = ('sample_id', 'time_ms', 'voltage_mv', 'unit', 'note')
MAX_CHARS, MAX_RECORDS = 8192, 100


def millivolt_text_to_microvolts(text):
    match = re.fullmatch(r'([+-]?)([0-9]{1,5})(?:\.([0-9]{1,3}))?', text)
    if match is None:
        raise ValueError('Declared ASCII millivolt decimal syntax required')
    sign, whole, fraction = match.groups()
    magnitude = int(whole) * 1000 + int((fraction or '').ljust(3, '0'))
    value = -magnitude if sign == '-' else magnitude
    if not -5_000_000 <= value <= 5_000_000:
        raise ValueError('Authored range is inclusive +/-5000 millivolts')
    return value


def parse_sensor_csv(text):
    if type(text) is not str or not text or len(text) > MAX_CHARS or '\x00' in text:
        raise ValueError('Exact nonempty str, bounded characters and no NUL required')
    reader = csv.reader(io.StringIO(text, newline=''), delimiter=',', quotechar='"',
                        doublequote=True, skipinitialspace=False, strict=True)
    accepted, seen_ids, previous_time = [], set(), -1
    try:
        header = next(reader)
        if len(header) != len(set(header)):
            raise ValueError('Duplicate header names are forbidden before mapping')
        if len(header) != len(HEADER) or set(header) != set(HEADER):
            raise ValueError('Exactly the declared five header names required')
        for fields in reader:
            if len(fields) != len(header):
                raise ValueError('Every logical data record needs exactly five fields')
            if len(accepted) >= MAX_RECORDS:
                raise ValueError('Authored limit is 100 data records')
            row = dict(zip(header, fields))
            sample_id = row['sample_id']
            if re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]{0,15}', sample_id) is None or sample_id in seen_ids:
                raise ValueError('Unique declared ASCII sample ID required')
            if re.fullmatch(r'[0-9]{1,7}', row['time_ms']) is None:
                raise ValueError('Timestamp must have one to seven ASCII integer digits')
            time_ms = int(row['time_ms'])
            if time_ms <= previous_time:
                raise ValueError('Authored timestamps must strictly increase')
            if row['unit'] != 'mV':
                raise ValueError('Exact mV unit required; no unit guessing')
            voltage_uv = millivolt_text_to_microvolts(row['voltage_mv'])
            if len(row['note']) > 80:
                raise ValueError('Authored note limit is 80 characters')
            accepted.append({'sample_id': sample_id, 'time_ms': time_ms,
                             'voltage_uv': voltage_uv, 'note': row['note']})
            seen_ids.add(sample_id)
            previous_time = time_ms
    except StopIteration as error:
        raise ValueError('A header and at least one data record are required') from error
    except csv.Error as error:
        raise ValueError('Malformed CSV syntax') from error
    if not accepted:
        raise ValueError('At least one data record is required')
    return accepted


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


def run_example():
    header = ','.join(HEADER) + '\n'
    fixture = (header + 'S01,0,125.250,mV,"probe, left"\n'
               'S02,5,-0.125,mV,"quoted ""check"""\n'
               'S03,10,0,mV,reference\n')
    expected = [{'sample_id': 'S01', 'time_ms': 0, 'voltage_uv': 125250, 'note': 'probe, left'},
                {'sample_id': 'S02', 'time_ms': 5, 'voltage_uv': -125, 'note': 'quoted "check"'},
                {'sample_id': 'S03', 'time_ms': 10, 'voltage_uv': 0, 'note': 'reference'}]
    parsed = parse_sensor_csv(fixture)
    reordered = 'note,unit,voltage_mv,time_ms,sample_id\n"probe, left",mV,125.250,0,S01\n'
    multiline = header + 'S01,0,1,mV,"first\nsecond"\n'
    numeric_bad = ['NaN', 'inf', '-inf', '1e3', '1_000', '.5', '1.', '+', '۱۲', '1.0001', ' 1']
    one_record = lambda value='1', unit='mV', timestamp='0', note='ok': header + f'S01,{timestamp},{value},{unit},{note}\n'
    hundred = header + ''.join(f'S{i:03d},{i},1,mV,ok\n' for i in range(100))
    checks = {
        'three_records_match_independent_hand_values_and_fields': parsed == expected,
        'reordered_unique_header_preserves_name_based_mapping': parse_sensor_csv(reordered) == expected[:1],
        'quoted_comma_and_doubled_quote_are_decoded_as_note_content': parsed[0]['note'] == 'probe, left' and parsed[1]['note'] == 'quoted "check"',
        'quoted_embedded_newline_is_one_logical_record': len(parse_sensor_csv(multiline)) == 1 and parse_sensor_csv(multiline)[0]['note'] == 'first\nsecond',
        'lf_crlf_and_optional_final_newline_preserve_values': parse_sensor_csv(fixture.replace('\n', '\r\n')) == parsed and parse_sensor_csv(fixture.rstrip('\n')) == parsed,
        'duplicate_headers_rejected_before_dictionary_mapping': rejects(lambda: parse_sensor_csv('sample_id,time_ms,voltage_mv,unit,unit\nS01,0,1,mV,mV\n')),
        'missing_and_extra_headers_rejected': rejects(lambda: parse_sensor_csv('sample_id,time_ms,voltage_mv,unit\nS01,0,1,mV\n')) and rejects(lambda: parse_sensor_csv(header.rstrip('\n') + ',extra\nS01,0,1,mV,ok,x\n')),
        'header_case_spaces_and_semicolon_dialect_not_guessed': all(rejects(lambda text=text: parse_sensor_csv(text)) for text in (fixture.replace('sample_id', 'Sample_ID', 1), fixture.replace('time_ms', ' time_ms', 1), fixture.replace(',', ';'))),
        'short_and_long_logical_records_rejected': rejects(lambda: parse_sensor_csv(header + 'S01,0,1,mV\n')) and rejects(lambda: parse_sensor_csv(header + 'S01,0,1,mV,ok,extra\n')),
        'empty_header_only_and_internal_blank_record_rejected': all(rejects(lambda text=text: parse_sensor_csv(text)) for text in ('', header, fixture + '\n')),
        'duplicate_or_invalid_sample_ids_rejected': rejects(lambda: parse_sensor_csv(header + 'S01,0,1,mV,a\nS01,1,2,mV,b\n')) and all(rejects(lambda sample_id=sample_id: parse_sensor_csv(header + f'{sample_id},0,1,mV,ok\n')) for sample_id in ('1bad', '_bad', 'نام', 'S' * 17)),
        'timestamps_require_ascii_integer_and_strict_order': all(rejects(lambda timestamp=timestamp: parse_sensor_csv(one_record(timestamp=timestamp))) for timestamp in ('-1', '1.0', '۵', '10000000')) and rejects(lambda: parse_sensor_csv(header + 'S01,1,1,mV,a\nS02,1,2,mV,b\n')) and rejects(lambda: parse_sensor_csv(header + 'S01,2,1,mV,a\nS02,1,2,mV,b\n')),
        'units_are_exact_and_no_conversion_is_guessed': all(rejects(lambda unit=unit: parse_sensor_csv(one_record(unit=unit))) for unit in ('V', 'mv', 'uV', '')),
        'nonfinite_exponent_localized_and_incomplete_numbers_rejected': all(rejects(lambda value=value: parse_sensor_csv(one_record(value=value))) for value in numeric_bad),
        'signed_decimal_text_is_exact_integer_microvolts': [millivolt_text_to_microvolts(x) for x in ('+1.001', '-0.001', '00001.010', '-0.000')] == [1001, -1, 1010, 0],
        'inclusive_voltage_boundaries_and_one_microvolt_outside': [millivolt_text_to_microvolts(x) for x in ('-5000', '+5000.000')] == [-5000000, 5000000] and all(rejects(lambda x=x: millivolt_text_to_microvolts(x)) for x in ('5000.001', '-5000.001')),
        'unterminated_or_badly_closed_quoted_csv_rejected': rejects(lambda: parse_sensor_csv(header + 'S01,0,1,mV,"unfinished\n')) and rejects(lambda: parse_sensor_csv(header + 'S01,0,1,mV,"bad"tail\n')),
        'document_record_and_note_limits_enforced': len(parse_sensor_csv(hundred)) == 100 and parse_sensor_csv(one_record(note='یادداشت'))[0]['note'] == 'یادداشت' and rejects(lambda: parse_sensor_csv(hundred + 'S100,100,1,mV,ok\n')) and all(rejects(lambda text=text: parse_sensor_csv(text)) for text in (None, b'a,b', 123, 'x' * (MAX_CHARS + 1), one_record(note='x' * 81), one_record(note='bad\x00note'))),
    }
    assert len(checks) == 18 and all(type(value) is bool and value for value in checks.values()), checks
    return {'python': platform.python_version(), 'dependencies': 'standard library only',
            'scope': 'authored small CSV text contract; no real file, decoding, calibration, spreadsheet or model execution',
            'fixture_csv': fixture, 'accepted_records': parsed,
            'limits': {'text_characters': MAX_CHARS, 'records': MAX_RECORDS, 'note_characters': 80,
                       'voltage_min_uv': -5000000, 'voltage_max_uv': 5000000},
            'millivolt_to_microvolt_scale': 1000, 'checks': checks}


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

سه رکورد با شناسه‌های S01 تا S03 و زمان‌های صفر، پنج و ده میلی‌ثانیه داریم. پاسخ مستقلِ دستی، مقدارهای صحیح ۱۲۵۲۵۰، منفی ۱۲۵ و صفر میکروولت است. شناسه باید الگوی ASCII اعلام‌شده و حداکثر شانزده نویسه داشته باشد؛ زمان، یک تا هفت رقم صحیح نامنفی است و باید در هر رکورد افزایش یابد. این زمان نسبی است؛ تاریخ تقویمی یا ساعت منطقهٔ زمانی نیست.

{
  "python": "3.14.5",
  "dependencies": "standard library only",
  "scope": "authored small CSV text contract; no real file, decoding, calibration, spreadsheet or model execution",
  "fixture_csv": "sample_id,time_ms,voltage_mv,unit,note\nS01,0,125.250,mV,\"probe, left\"\nS02,5,-0.125,mV,\"quoted \"\"check\"\"\"\nS03,10,0,mV,reference\n",
  "accepted_records": [
    {
      "sample_id": "S01",
      "time_ms": 0,
      "voltage_uv": 125250,
      "note": "probe, left"
    },
    {
      "sample_id": "S02",
      "time_ms": 5,
      "voltage_uv": -125,
      "note": "quoted \"check\""
    },
    {
      "sample_id": "S03",
      "time_ms": 10,
      "voltage_uv": 0,
      "note": "reference"
    }
  ],
  "limits": {
    "text_characters": 8192,
    "records": 100,
    "note_characters": 80,
    "voltage_min_uv": -5000000,
    "voltage_max_uv": 5000000
  },
  "millivolt_to_microvolt_scale": 1000,
  "checks": {
    "three_records_match_independent_hand_values_and_fields": true,
    "reordered_unique_header_preserves_name_based_mapping": true,
    "quoted_comma_and_doubled_quote_are_decoded_as_note_content": true,
    "quoted_embedded_newline_is_one_logical_record": true,
    "lf_crlf_and_optional_final_newline_preserve_values": true,
    "duplicate_headers_rejected_before_dictionary_mapping": true,
    "missing_and_extra_headers_rejected": true,
    "header_case_spaces_and_semicolon_dialect_not_guessed": true,
    "short_and_long_logical_records_rejected": true,
    "empty_header_only_and_internal_blank_record_rejected": true,
    "duplicate_or_invalid_sample_ids_rejected": true,
    "timestamps_require_ascii_integer_and_strict_order": true,
    "units_are_exact_and_no_conversion_is_guessed": true,
    "nonfinite_exponent_localized_and_incomplete_numbers_rejected": true,
    "signed_decimal_text_is_exact_integer_microvolts": true,
    "inclusive_voltage_boundaries_and_one_microvolt_outside": true,
    "unterminated_or_badly_closed_quoted_csv_rejected": true,
    "document_record_and_note_limits_enforced": true
  }
}

بررسی‌ها تغییر ترتیب ستون، نقل‌قول، مرز عدد، واحد، تکرار شناسه، طول رکورد و محدودیت ورودی را پوشش می‌دهند. صد رکورد پذیرفته و صد و یکمی رد می‌شود. سقف متن ۸۱۹۲ نویسه و یادداشت هشتاد نویسه است؛ این‌ها شمار بایت UTF-8 یا اندازه‌گیری حافظه نیستند. یادداشت فارسی مجاز و NUL ممنوع است. همهٔ رکوردها در فهرست کوچک نگه داشته می‌شوند؛ ادعای پردازش نامحدود یا حافظهٔ ثابت نداریم.

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

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

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

تمرین دوم: آیا نقل‌قول دور 1e3 آن را مجاز می‌کند؟ پاسخ: خیر؛ نقل‌قول، مرز فیلد را مدیریت می‌کند و دستور زبان عدد را تغییر نمی‌دهد. متن حاصل همچنان قرارداد اعشار ثابت ما را نقض می‌کند.

تمرین سوم: ورودی -0.001 چه می‌شود؟ پاسخ: منفی یک میکروولت صحیح؛ تبدیل بدون ممیز شناور انجام می‌شود. هرچند عدد دقیقاً ذخیره شده، دقت فیزیکیِ اندازه‌گیری از همین محاسبه قابل نتیجه‌گیری نیست.

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

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

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