scout_ground_truth.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. #!/usr/bin/env python3
  2. """
  3. One-off: build a ground-truth dataset of (component -> real LCSC category)
  4. from the two examples/full BOMs that happen to carry real LCSC part numbers.
  5. Reads the raw BOM columns directly (not through the normal ingestion pipeline —
  6. production BOMs won't have LCSC part numbers, so there's nothing to wire up
  7. there). For each row, looks up the part's real category via product_lookup,
  8. and writes one JSON line per row to ground_truth.ndjson:
  9. {"designator": "C38", "value": "330u, 63V", "footprint": "...",
  10. "part": "C437643", "category_id": 1140, "category_name": "Aluminum Electrolytic Capacitors",
  11. "parent_category_name": "Capacitors"}
  12. Usage:
  13. python -m bom_assistant.suppliers.lcsc.scout_ground_truth
  14. """
  15. from __future__ import annotations
  16. import csv
  17. import json
  18. import re
  19. from pathlib import Path
  20. import openpyxl
  21. from bom_assistant.suppliers.lcsc.product_lookup import lookup_product, sleep_between_requests
  22. _EXAMPLES_DIR = Path(__file__).resolve().parents[3] / "examples" / "full"
  23. _OUT_PATH = Path(__file__).parent / "ground_truth.ndjson"
  24. def _rows_from_csv(path: Path) -> list[dict]:
  25. with path.open(encoding="utf-8-sig", newline="") as f:
  26. reader = csv.DictReader(f)
  27. return [
  28. {
  29. "source": path.name,
  30. "designators": row.get("Designator", ""),
  31. "value": row.get("Comment", ""),
  32. "footprint": row.get("Footprint", ""),
  33. "part": (row.get("LCSC Part #") or "").strip(),
  34. }
  35. for row in reader
  36. if (row.get("LCSC Part #") or "").strip()
  37. ]
  38. def _rows_from_xlsx(path: Path) -> list[dict]:
  39. wb = openpyxl.load_workbook(path, data_only=True)
  40. ws = wb.active
  41. header = [str(c.value).strip() if c.value is not None else "" for c in next(ws.iter_rows(min_row=1, max_row=1))]
  42. idx = {name: i for i, name in enumerate(header)}
  43. out: list[dict] = []
  44. for row in ws.iter_rows(min_row=2, values_only=True):
  45. supplier = str(row[idx["Supplier"]] or "").strip() if "Supplier" in idx else ""
  46. part = str(row[idx["Supplier Part"]] or "").strip() if "Supplier Part" in idx else ""
  47. if supplier.upper() != "LCSC" or not part:
  48. continue
  49. out.append({
  50. "source": path.name,
  51. "designators": str(row[idx["Designator"]] or "") if "Designator" in idx else "",
  52. "value": str(row[idx["Value"]] or row[idx["Comment"]] or "") if "Value" in idx or "Comment" in idx else "",
  53. "footprint": str(row[idx["Footprint"]] or "") if "Footprint" in idx else "",
  54. "part": part,
  55. })
  56. return out
  57. def _first_designator(designators: str) -> str:
  58. parts = re.split(r"[,;\s]+", designators.strip())
  59. return parts[0] if parts else ""
  60. def main() -> None:
  61. sources = [
  62. (_EXAMPLES_DIR / "bom (1).csv", _rows_from_csv),
  63. (_EXAMPLES_DIR / "BOM_imx415_DigitalFPV (1).xlsx", _rows_from_xlsx),
  64. ]
  65. rows: list[dict] = []
  66. for path, reader in sources:
  67. if not path.exists():
  68. print(f"warn: missing {path}, skipping")
  69. continue
  70. found = reader(path)
  71. print(f"{path.name}: {len(found)} rows with an LCSC part #")
  72. rows.extend(found)
  73. print(f"\nLooking up {len(rows)} parts on lcsc.com …")
  74. written = 0
  75. with _OUT_PATH.open("w", encoding="utf-8") as out:
  76. for i, row in enumerate(rows, 1):
  77. part = row["part"]
  78. info = lookup_product(part)
  79. if info is None:
  80. print(f" [{i}/{len(rows)}] {part}: lookup failed — skipping")
  81. sleep_between_requests()
  82. continue
  83. entry = {
  84. "source": row["source"],
  85. "designator": _first_designator(row["designators"]),
  86. "designators": row["designators"],
  87. "value": row["value"],
  88. "footprint": row["footprint"],
  89. "part": part,
  90. "category_id": info.category_id,
  91. "category_name": info.category_name,
  92. "parent_category_name": info.parent_category_name,
  93. "product_name": info.product_name,
  94. }
  95. out.write(json.dumps(entry, ensure_ascii=False) + "\n")
  96. written += 1
  97. print(f" [{i}/{len(rows)}] {part} -> {info.category_name}")
  98. sleep_between_requests()
  99. print(f"\nWrote {written}/{len(rows)} ground-truth entries to {_OUT_PATH}")
  100. if __name__ == "__main__":
  101. main()