normalizer.py 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. from __future__ import annotations
  2. import re
  3. import uuid
  4. from bom_assistant.classification.classifier import classify, is_non_electronic
  5. from bom_assistant.classification.param_extractor import extract_params
  6. from bom_assistant.session.models import BomRow, RowState
  7. _DNP_KEYWORDS = re.compile(
  8. r"\b(dnp|do\s+not\s+place|do\s+not\s+populate|np|not\s+placed)\b",
  9. re.IGNORECASE,
  10. )
  11. _DNP_TRUTHY = {"1", "true", "yes", "dnp", "x"}
  12. # Range designator: "C1-5", "R56-61"
  13. _RANGE_RE = re.compile(r"^([A-Za-z]+)(\d+)-(\d+)$")
  14. def normalize(mapped_rows: list[dict[str, str]]) -> list[BomRow]:
  15. rows: list[BomRow] = []
  16. for raw in mapped_rows:
  17. row = _normalize_row(raw)
  18. if row is not None:
  19. rows.append(row)
  20. return rows
  21. def _normalize_row(raw: dict[str, str]) -> BomRow | None:
  22. value = raw.get("value", "").strip()
  23. # skip empty or KiCad placeholder
  24. if not value or value == "~":
  25. return None
  26. designators = _parse_designators(raw.get("designators", ""))
  27. quantity = _parse_quantity(raw.get("quantity", ""))
  28. footprint = raw.get("footprint", "").strip() or None
  29. # pull part_number / manufacturer from mapped columns if present
  30. part_number = raw.get("part_number", "").strip() or None
  31. manufacturer = raw.get("manufacturer", "").strip() or None
  32. # state determination
  33. state = _determine_state(value, footprint, quantity, raw)
  34. category = classify(designators, value)
  35. params = extract_params(value, footprint, category)
  36. # inject part_number / manufacturer from mapped columns if extractor didn't find them
  37. if part_number and not params.part_number:
  38. params.part_number = part_number
  39. if manufacturer and not params.manufacturer:
  40. params.manufacturer = manufacturer
  41. return BomRow(
  42. row_id=str(uuid.uuid4()),
  43. designators=designators,
  44. quantity=quantity,
  45. raw_value=value,
  46. footprint=footprint,
  47. category=category,
  48. normalized_params=params,
  49. state=state,
  50. )
  51. def _parse_designators(s: str) -> list[str]:
  52. if not s:
  53. return []
  54. # split on comma, semicolon, or whitespace
  55. parts = re.split(r"[,;\s]+", s.strip())
  56. expanded: list[str] = []
  57. for part in parts:
  58. part = part.strip()
  59. if not part:
  60. continue
  61. expanded.extend(_expand_range(part))
  62. # deduplicate while preserving order
  63. seen: set[str] = set()
  64. result: list[str] = []
  65. for d in expanded:
  66. if d not in seen:
  67. seen.add(d)
  68. result.append(d)
  69. return sorted(result, key=lambda x: (re.sub(r"\d", "", x), int(re.sub(r"\D", "", x) or 0)))
  70. def _expand_range(s: str) -> list[str]:
  71. m = _RANGE_RE.match(s)
  72. if m:
  73. prefix = m.group(1)
  74. start, end = int(m.group(2)), int(m.group(3))
  75. if start <= end and (end - start) < 200: # sanity cap
  76. return [f"{prefix}{i}" for i in range(start, end + 1)]
  77. return [s]
  78. def _parse_quantity(s: str) -> int:
  79. s = s.strip()
  80. if not s:
  81. return 1
  82. try:
  83. return max(0, int(float(s)))
  84. except (ValueError, TypeError):
  85. return 1
  86. def _determine_state(
  87. value: str,
  88. footprint: str | None,
  89. quantity: int,
  90. raw: dict[str, str],
  91. ) -> RowState:
  92. if quantity == 0:
  93. return RowState.flagged
  94. combined = f"{value} {footprint or ''}"
  95. if _DNP_KEYWORDS.search(combined):
  96. return RowState.flagged
  97. # check explicit boolean DNP columns
  98. for col in ("DNP", "Exclude from BOM", "Mounting", "_raw_DNP", "_raw_Exclude from BOM", "_raw_Mounting"):
  99. col_val = raw.get(col, "").strip().lower()
  100. if col_val in _DNP_TRUTHY:
  101. return RowState.flagged
  102. if is_non_electronic(value):
  103. return RowState.flagged
  104. return RowState.pending