Spaces:
Sleeping
Sleeping
| # -*- coding: utf-8 -*- | |
| """ | |
| VIN (Vehicle Identification Number) Validator | |
| Standard: ISO 3779 / SAE J853 | |
| Structure (17 characters): | |
| Position 1-3: WMI (World Manufacturer Identifier) | |
| Position 4-8: VDS (Vehicle Descriptor Section) | |
| Position 9: Check digit (0-9 or X) | |
| Position 10: Model year code | |
| Position 11: Plant code | |
| Position 12-17: Sequential production number (VIS tail) | |
| """ | |
| import logging | |
| logger = logging.getLogger(__name__) | |
| # Transliteration values for check digit calculation (ISO 3779) | |
| _TRANSLITERATION = { | |
| 'A': 1, 'B': 2, 'C': 3, 'D': 4, 'E': 5, 'F': 6, 'G': 7, 'H': 8, | |
| 'J': 1, 'K': 2, 'L': 3, 'M': 4, 'N': 5, 'P': 7, 'R': 9, | |
| 'S': 2, 'T': 3, 'U': 4, 'V': 5, 'W': 6, 'X': 7, 'Y': 8, 'Z': 9, | |
| } | |
| # Position weights for check digit calculation | |
| _WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2] | |
| # Model year codes (position 10) | |
| _YEAR_CODES = { | |
| 'A': 2010, 'B': 2011, 'C': 2012, 'D': 2013, 'E': 2014, | |
| 'F': 2015, 'G': 2016, 'H': 2017, 'J': 2018, 'K': 2019, | |
| 'L': 2020, 'M': 2021, 'N': 2022, 'P': 2023, 'R': 2024, | |
| 'S': 2025, 'T': 2026, 'V': 2027, 'W': 2028, 'X': 2029, 'Y': 2030, | |
| '1': 2001, '2': 2002, '3': 2003, '4': 2004, '5': 2005, | |
| '6': 2006, '7': 2007, '8': 2008, '9': 2009, | |
| } | |
| # Valid characters in VIN (I, O, Q excluded) | |
| _VALID_CHARS = set('ABCDEFGHJKLMNPRSTUVWXYZ0123456789') | |
| # Korean manufacturer WMIs | |
| KOREAN_WMIS = { | |
| 'KMH': 'Hyundai (passenger)', | |
| 'KMJ': 'Hyundai (bus/truck)', | |
| 'KME': 'Hyundai (EV/special)', | |
| 'KNA': 'Kia', | |
| 'KNC': 'Kia (commercial)', | |
| 'KNM': 'Renault Samsung', | |
| 'KND': 'Kia (SUV)', | |
| 'KPT': 'SsangYong', | |
| 'KPA': 'SsangYong', | |
| 'KL1': 'GM Korea (Chevrolet)', | |
| 'KLA': 'Daewoo/GM Korea', | |
| 'KLB': 'Daewoo/GM Korea', | |
| 'KMF': 'Hyundai (medium truck)', | |
| 'KMK': 'Hyundai (special)', | |
| } | |
| def compute_check_digit(vin): | |
| """ | |
| Compute the VIN check digit (position 9) per ISO 3779. | |
| Args: | |
| vin: 17-character VIN string (position 9 can be anything) | |
| Returns: | |
| Expected check digit character ('0'-'9' or 'X'), or None if invalid | |
| """ | |
| if not vin or len(vin) != 17: | |
| return None | |
| total = 0 | |
| for i, char in enumerate(vin.upper()): | |
| if i == 8: # Skip position 9 (check digit itself) | |
| continue | |
| if char.isdigit(): | |
| value = int(char) | |
| elif char in _TRANSLITERATION: | |
| value = _TRANSLITERATION[char] | |
| else: | |
| return None # Invalid character | |
| total += value * _WEIGHTS[i] | |
| remainder = total % 11 | |
| return 'X' if remainder == 10 else str(remainder) | |
| def decode_model_year(vin): | |
| """Decode model year from VIN position 10.""" | |
| if not vin or len(vin) < 10: | |
| return None | |
| year_char = vin[9].upper() | |
| return _YEAR_CODES.get(year_char) | |
| def decode_wmi(vin): | |
| """Decode World Manufacturer Identifier (positions 1-3).""" | |
| if not vin or len(vin) < 3: | |
| return None | |
| wmi = vin[:3].upper() | |
| return KOREAN_WMIS.get(wmi) | |
| def correct_vin_ocr(vin): | |
| """ | |
| Fix common OCR misreads in VIN. | |
| Strategy: | |
| 1. Replace universally invalid chars (I→1, O→0, Q→0) | |
| 2. If check digit fails, try single-char corrections to find valid VIN | |
| """ | |
| if not vin: | |
| return vin | |
| vin = vin.upper().strip() | |
| # Step 1: Fix universally invalid VIN characters | |
| vin = vin.replace('O', '0').replace('I', '1').replace('Q', '0') | |
| # Step 2: If check digit passes, we're done | |
| if len(vin) == 17: | |
| expected = compute_check_digit(vin) | |
| actual = vin[8] | |
| if expected and actual == expected: | |
| return vin | |
| # Step 3: Try single-character corrections at each position | |
| # Only try ambiguous OCR pairs (S↔5, B↔8, Z↔2, G↔6) | |
| ambiguous_pairs = [ | |
| ('5', 'S'), ('S', '5'), | |
| ('8', 'B'), ('B', '8'), | |
| ('2', 'Z'), ('Z', '2'), | |
| ('6', 'G'), ('G', '6'), | |
| ('0', 'D'), ('D', '0'), | |
| ] | |
| for pos in range(17): | |
| if pos == 8: | |
| continue # Don't modify check digit position | |
| original_char = vin[pos] | |
| for from_char, to_char in ambiguous_pairs: | |
| if original_char == from_char: | |
| candidate = vin[:pos] + to_char + vin[pos + 1:] | |
| chk = compute_check_digit(candidate) | |
| if chk and candidate[8] == chk: | |
| logger.info(f"VIN OCR correction: pos {pos + 1} '{from_char}'→'{to_char}' ({vin}→{candidate})") | |
| return candidate | |
| return vin | |
| def is_valid_structure(vin): | |
| """ | |
| Validate VIN structure per ISO 3779. | |
| Returns: | |
| (is_valid, message) tuple | |
| """ | |
| if not vin: | |
| return False, "Empty VIN" | |
| vin = vin.upper().strip() | |
| # Length check | |
| if len(vin) != 17: | |
| return False, f"Invalid length: {len(vin)} (expected 17)" | |
| # Character set check | |
| invalid_chars = set(vin) - _VALID_CHARS | |
| if invalid_chars: | |
| return False, f"Invalid characters: {invalid_chars}" | |
| # Position 12-17 should be numeric for Korean vehicles (most manufacturers) | |
| serial = vin[11:17] | |
| if vin[0] == 'K' and not serial.isdigit(): | |
| # Some Korean VINs have alphanumeric serial, so this is a warning not rejection | |
| pass | |
| return True, "Valid structure" | |
| def validate_check_digit(vin): | |
| """ | |
| Validate VIN check digit (position 9). | |
| Note: Check digit is mandatory for North American vehicles (FMVSS/CMVSS) | |
| but optional for vehicles in other markets. Korean domestic vehicles | |
| may not always follow check digit rules, but exported ones do. | |
| Returns: | |
| (is_valid, message) tuple | |
| """ | |
| if not vin or len(vin) != 17: | |
| return False, "Cannot validate check digit" | |
| expected = compute_check_digit(vin) | |
| actual = vin[8] | |
| if expected is None: | |
| return False, "Cannot compute check digit (invalid characters)" | |
| if actual == expected: | |
| return True, f"Check digit valid ({actual})" | |
| else: | |
| return False, f"Check digit mismatch: expected '{expected}', got '{actual}'" | |
| class VINValidator: | |
| """ | |
| Validates Vehicle Identification Numbers (VIN). | |
| Standard: ISO 3779 / SAE J853 | |
| Validation levels: | |
| 1. Structure: length, character set | |
| 2. Check digit: position 9 verification | |
| 3. WMI: manufacturer identification | |
| """ | |
| def validate(vin): | |
| """ | |
| Full VIN validation. | |
| Returns: | |
| (is_valid, message) tuple | |
| """ | |
| if not vin: | |
| return False, "Empty VIN" | |
| vin = vin.upper().replace(" ", "") | |
| # Structure validation | |
| valid, msg = is_valid_structure(vin) | |
| if not valid: | |
| return False, msg | |
| # Check digit validation | |
| chk_valid, chk_msg = validate_check_digit(vin) | |
| # WMI info | |
| manufacturer = decode_wmi(vin) | |
| year = decode_model_year(vin) | |
| info_parts = [] | |
| if manufacturer: | |
| info_parts.append(manufacturer) | |
| if year: | |
| info_parts.append(f"MY{year}") | |
| if chk_valid: | |
| info = f"Valid ({', '.join(info_parts)})" if info_parts else "Valid" | |
| return True, info | |
| else: | |
| # Korean domestic vehicles may not use check digit | |
| # Accept with warning if structure is valid | |
| info = f"Valid (structure OK, {chk_msg}" | |
| if info_parts: | |
| info += f", {', '.join(info_parts)}" | |
| info += ")" | |
| return True, info | |
| def calculate_similarity(vin1, vin2): | |
| """Simple character-level similarity between two VINs.""" | |
| if not vin1 or not vin2: | |
| return 0.0 | |
| matches = sum(c1 == c2 for c1, c2 in zip(vin1, vin2)) | |
| return matches / max(len(vin1), len(vin2)) | |