param_extractor.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. from __future__ import annotations
  2. import json
  3. import re
  4. from pathlib import Path
  5. from bom_assistant.session.models import ComponentCategory, NormalizedParams
  6. _PATTERNS_PATH = Path(__file__).parent / "package_patterns.json"
  7. # SI multipliers for passive values
  8. _MULT: dict[str, float] = {
  9. "p": 1e-12, "n": 1e-9, "u": 1e-6, "µ": 1e-6,
  10. "m": 1e-3, "k": 1e3, "K": 1e3, "M": 1e6, "G": 1e9,
  11. }
  12. # European notation: 2k2, 4k7, 1R5, 3n3
  13. _EURO_RE = re.compile(r"^(\d+)([pnuµmkKMGrR])(\d+)$")
  14. # Standard: 100R, 10k, 4.7k, 100n, 1nF, 22uF, 0.001R
  15. _STD_RE = re.compile(r"^(\d+\.?\d*)\s*([pnuµmkKMG]?)\s*([FfHhRrΩ]?)$")
  16. # Frequency: 8MHz, 32.768kHz
  17. _FREQ_RE = re.compile(r"(\d+\.?\d*)\s*(k|M|G)?Hz", re.IGNORECASE)
  18. def extract_params(
  19. raw_value: str,
  20. footprint: str | None,
  21. category: ComponentCategory,
  22. ) -> NormalizedParams:
  23. params = NormalizedParams()
  24. params.package = _extract_package(footprint)
  25. cleaned = _preprocess(raw_value)
  26. # try each comma-separated token, not just the first
  27. tokens = [t.strip() for t in re.split(r"[,;]", cleaned) if t.strip()]
  28. primary = tokens[0] if tokens else ""
  29. if category == ComponentCategory.capacitor:
  30. for tok in tokens:
  31. _parse_capacitance(tok, params)
  32. if params.value is not None:
  33. break
  34. # fallback: scan entire string for embedded capacitance
  35. if params.value is None:
  36. _extract_capacitance_from_description(cleaned, params)
  37. elif category == ComponentCategory.resistor:
  38. for tok in tokens:
  39. _parse_resistance(tok, params)
  40. if params.value is not None:
  41. break
  42. if params.value is None:
  43. _extract_resistance_from_description(cleaned, params)
  44. elif category == ComponentCategory.inductor:
  45. for tok in tokens:
  46. _parse_inductance(tok, params)
  47. if params.value is not None:
  48. break
  49. elif category == ComponentCategory.crystal:
  50. _parse_frequency(cleaned, params)
  51. elif category in (ComponentCategory.ic, ComponentCategory.mosfet):
  52. params.part_number = raw_value.strip()
  53. elif category == ComponentCategory.sensor:
  54. m = re.search(r"(\d+\.?\d*)\s*([kKMΩRr]?)", primary.split()[-1] if primary else "")
  55. if m:
  56. _parse_resistance(m.group(0), params)
  57. # cross-category extractions from full string
  58. _extract_voltage(cleaned, params)
  59. _extract_current(cleaned, params)
  60. _extract_power(cleaned, params)
  61. _extract_tolerance(cleaned, params)
  62. return params
  63. # ---------------------------------------------------------------------------
  64. # Pre-processing
  65. # ---------------------------------------------------------------------------
  66. def _preprocess(s: str) -> str:
  67. # strip KiCad placeholder
  68. if s.strip() == "~":
  69. return ""
  70. # normalize unicode ohm
  71. s = s.replace("Ω", "R").replace("ω", "r")
  72. # European decimal: "3,3K" → "3.3K" (digit comma digit followed by unit)
  73. s = re.sub(r"(\d+),(\d+)([pnuµmkKMGrRFfHh])", lambda m: f"{m.group(1)}.{m.group(2)}{m.group(3)}", s)
  74. return s
  75. # ---------------------------------------------------------------------------
  76. # Capacitance
  77. # ---------------------------------------------------------------------------
  78. def _parse_capacitance(token: str, params: NormalizedParams) -> None:
  79. # ensure trailing F for bare prefix (e.g. "100n" → "100nF")
  80. m = re.match(r"^(\d+\.?\d*)\s*([pnuµ])[fF]?$", token, re.IGNORECASE)
  81. if m:
  82. num, prefix = m.group(1), m.group(2).lower()
  83. mult = _MULT.get(prefix, 1)
  84. params.value = float(num) * mult
  85. params.unit = "F"
  86. params.value_str = f"{num}{prefix}F"
  87. return
  88. # European: 4n7, 2u2
  89. m = _EURO_RE.match(token)
  90. if m and m.group(2).lower() in ("p", "n", "u", "µ"):
  91. prefix_char = m.group(2).lower()
  92. val = (int(m.group(1)) + int(m.group(3)) / 10 ** len(m.group(3))) * _MULT[prefix_char]
  93. params.value = val
  94. params.unit = "F"
  95. params.value_str = token
  96. return
  97. # ---------------------------------------------------------------------------
  98. # Resistance
  99. # ---------------------------------------------------------------------------
  100. def _parse_resistance(token: str, params: NormalizedParams) -> None:
  101. token = token.strip()
  102. # European: 2k2, 4k7, 1R5, 3n3
  103. m = _EURO_RE.match(token)
  104. if m:
  105. prefix_char = m.group(2)
  106. mult = _MULT.get(prefix_char, _MULT.get(prefix_char.lower(), 1))
  107. if prefix_char.lower() in ("r",):
  108. mult = 1
  109. val = (int(m.group(1)) + int(m.group(3)) / 10 ** len(m.group(3))) * mult
  110. params.value = val
  111. params.unit = "Ω"
  112. params.value_str = token
  113. return
  114. # Standard: 100R, 10k, 0.001R, 220k, 4.7k
  115. m = re.match(r"^(\d+\.?\d*)\s*([kKMmrRΩ]?)$", token)
  116. if m:
  117. num, suffix = m.group(1), m.group(2)
  118. mult_map = {"k": 1e3, "K": 1e3, "m": 1e-3, "M": 1e6, "r": 1, "R": 1, "Ω": 1, "": 1}
  119. mult = mult_map.get(suffix, 1)
  120. params.value = float(num) * mult
  121. params.unit = "Ω"
  122. params.value_str = token
  123. return
  124. # ---------------------------------------------------------------------------
  125. # Inductance
  126. # ---------------------------------------------------------------------------
  127. def _parse_inductance(token: str, params: NormalizedParams) -> None:
  128. m = re.match(r"^(\d+\.?\d*)\s*([pnuµm]?)[hH]?$", token)
  129. if m:
  130. num, prefix = m.group(1), m.group(2).lower()
  131. mult = _MULT.get(prefix, 1) if prefix else 1
  132. params.value = float(num) * mult
  133. params.unit = "H"
  134. params.value_str = f"{num}{prefix}H" if prefix else f"{num}H"
  135. return
  136. # European: 4u7
  137. m = _EURO_RE.match(token)
  138. if m and m.group(2).lower() in ("p", "n", "u", "µ", "m"):
  139. prefix_char = m.group(2).lower()
  140. val = (int(m.group(1)) + int(m.group(3)) / 10 ** len(m.group(3))) * _MULT[prefix_char]
  141. params.value = val
  142. params.unit = "H"
  143. params.value_str = token
  144. return
  145. # ---------------------------------------------------------------------------
  146. # Description fallback scanners (long strings like "CAP CER 0.1UF 100V X7R 0603")
  147. # ---------------------------------------------------------------------------
  148. def _extract_capacitance_from_description(s: str, params: NormalizedParams) -> None:
  149. m = re.search(r"\b(\d+\.?\d*)\s*([pnuµ])[fF]?\b", s, re.IGNORECASE)
  150. if m:
  151. num, prefix = m.group(1), m.group(2).lower()
  152. mult = _MULT.get(prefix, 1)
  153. params.value = float(num) * mult
  154. params.unit = "F"
  155. params.value_str = f"{num}{prefix}F"
  156. def _extract_resistance_from_description(s: str, params: NormalizedParams) -> None:
  157. m = re.search(r"\b(\d+\.?\d*)\s*([kKMm]?)\s*[RrΩ]\b", s)
  158. if m:
  159. num, prefix = m.group(1), m.group(2)
  160. mult = {"k": 1e3, "K": 1e3, "m": 1e-3, "M": 1e6, "": 1}.get(prefix, 1)
  161. params.value = float(num) * mult
  162. params.unit = "Ω"
  163. params.value_str = m.group(0).strip()
  164. # ---------------------------------------------------------------------------
  165. # Frequency
  166. # ---------------------------------------------------------------------------
  167. def _parse_frequency(s: str, params: NormalizedParams) -> None:
  168. m = _FREQ_RE.search(s)
  169. if m:
  170. num = float(m.group(1))
  171. prefix = (m.group(2) or "").lower()
  172. mult = {"k": 1e3, "m": 1e6, "g": 1e9}.get(prefix, 1)
  173. params.value = num * mult
  174. params.unit = "Hz"
  175. params.value_str = m.group(0)
  176. # ---------------------------------------------------------------------------
  177. # Cross-category extractions
  178. # ---------------------------------------------------------------------------
  179. def _extract_voltage(s: str, params: NormalizedParams) -> None:
  180. if params.voltage_rating is not None:
  181. return
  182. m = re.search(r"(\d+\.?\d*)\s*[Vv]\b", s)
  183. if m:
  184. params.voltage_rating = float(m.group(1))
  185. def _extract_current(s: str, params: NormalizedParams) -> None:
  186. if params.current_rating is not None:
  187. return
  188. m = re.search(r"(\d+\.?\d*)\s*[Aa]\b", s)
  189. if m:
  190. params.current_rating = float(m.group(1))
  191. def _extract_power(s: str, params: NormalizedParams) -> None:
  192. if params.power_rating is not None:
  193. return
  194. m = re.search(r"(\d+\.?\d*)\s*W\b", s)
  195. if m:
  196. params.power_rating = float(m.group(1))
  197. def _extract_tolerance(s: str, params: NormalizedParams) -> None:
  198. if params.tolerance is not None:
  199. return
  200. m = re.search(r"(\d+\.?\d*)\s*(ppm|%)", s, re.IGNORECASE)
  201. if m:
  202. params.tolerance = f"{m.group(1)}{m.group(2)}"
  203. # ---------------------------------------------------------------------------
  204. # Package — file-backed patterns + AI fallback
  205. # ---------------------------------------------------------------------------
  206. def _load_package_data() -> dict:
  207. with open(_PATTERNS_PATH, encoding="utf-8") as f:
  208. return json.load(f)
  209. def _save_package_data(data: dict) -> None:
  210. with open(_PATTERNS_PATH, "w", encoding="utf-8") as f:
  211. json.dump(data, f, indent=2, ensure_ascii=False)
  212. def _extract_package(footprint: str | None) -> str | None:
  213. if not footprint:
  214. return None
  215. data = _load_package_data()
  216. # 1. exact match
  217. if footprint in data["known_footprints"]:
  218. return data["known_footprints"][footprint]
  219. # 2. regex cascade
  220. for raw_pattern in data["patterns"]:
  221. m = re.search(raw_pattern, footprint)
  222. if m:
  223. return m.group(1)
  224. # 3. AI fallback
  225. package = _ai_identify_package(footprint)
  226. if package:
  227. data["known_footprints"][footprint] = package
  228. _save_package_data(data)
  229. return package
  230. def _ai_identify_package(footprint: str) -> str | None:
  231. try:
  232. import anthropic
  233. client = anthropic.Anthropic()
  234. msg = client.messages.create(
  235. model="claude-haiku-4-5-20251001",
  236. max_tokens=50,
  237. messages=[{
  238. "role": "user",
  239. "content": (
  240. f"What is the standard electronic package name for this PCB footprint identifier: '{footprint}'?\n"
  241. "Reply with ONLY the package name (e.g. SOIC-8, 0603, HTSOP-8, TO-220) "
  242. "or 'unknown' if it cannot be determined. No explanation."
  243. ),
  244. }],
  245. )
  246. result = msg.content[0].text.strip()
  247. if result.lower() == "unknown" or not result:
  248. return None
  249. return result
  250. except Exception:
  251. return None