From c1cd199e5acd025e30713774c7f766046f0ac18f Mon Sep 17 00:00:00 2001 From: David Rice Date: Fri, 10 Apr 2026 14:55:30 +0100 Subject: [PATCH] Updates --- nexarxclaude.py | 140 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 137 insertions(+), 3 deletions(-) diff --git a/nexarxclaude.py b/nexarxclaude.py index 326e542..ea0ab3a 100644 --- a/nexarxclaude.py +++ b/nexarxclaude.py @@ -271,6 +271,25 @@ def _mpn_match(a: str, b: str) -> bool: return False +def _norm_elec(s: str) -> str: + """Normalise electrical value: lowercase, µ→u, strip non-alphanumeric.""" + if not s: + return "" + s = s.strip().lower() + for frm, to in [('\u00b5', 'u'), ('\u03bc', 'u'), ('µ', 'u'), ('μ', 'u'), + ('\u03a9', 'ohm'), ('\u2126', 'ohm'), ('mf', 'uf')]: + s = s.replace(frm, to) + return re.sub(r'[^a-z0-9]', '', s) + + +def _elec_match(a: str, b: str) -> bool: + """True if two electrical values match (prefix-tolerant after normalisation).""" + na, nb = _norm_elec(a), _norm_elec(b) + if not na or not nb: + return False + return na == nb or nb.startswith(na) or na.startswith(nb) + + def load_bom_data(bom_path: str) -> list | None: """Load BOM xlsx and return a list of entry dicts. Returns None if no 'bom' tab is found.""" @@ -289,21 +308,40 @@ def load_bom_data(bom_path: str) -> list | None: return headers[c] return None + def cell(row, col): + return str(row[col]).strip() if col is not None and len(row) > col and row[col] is not None else "" + mfr_col = find_col("manufacturer", "mfr", "manufacturer name") mpn_col = find_col("mpn", "manufacturer part number", "manufacturer part no", "part number") cost_col = find_col("unit cost", "unit price", "cost", "price") + desc_col = find_col("description", "desc", "component description") + res_col = find_col("resistance", "resistance (ohms)") + tol_col = find_col("tolerance", "tol") + cap_col = find_col("capacitance", "capacitance (farads)") + volt_col = find_col("voltage", "voltage rating", "voltage - rated", "rated voltage") + fp_col = find_col("footprint", "case", "package", "package / case", "case - imperial", "case - metric") entries = [] for row in bom_sheet.iter_rows(min_row=2, values_only=True): - mfr = str(row[mfr_col]).strip() if mfr_col is not None and len(row) > mfr_col and row[mfr_col] is not None else "" - mpn = str(row[mpn_col]).strip() if mpn_col is not None and len(row) > mpn_col and row[mpn_col] is not None else "" + mfr = cell(row, mfr_col) + mpn = cell(row, mpn_col) if not mfr and not mpn: continue try: cost = float(row[cost_col]) if cost_col is not None and len(row) > cost_col and row[cost_col] is not None else 0.0 except (ValueError, TypeError): cost = 0.0 - entries.append({"manufacturer": mfr, "mpn": mpn, "unit_cost": cost}) + entries.append({ + "manufacturer": mfr, + "mpn": mpn, + "unit_cost": cost, + "description": cell(row, desc_col), + "resistance": cell(row, res_col), + "tolerance": cell(row, tol_col), + "capacitance": cell(row, cap_col), + "voltage": cell(row, volt_col), + "footprint": cell(row, fp_col), + }) return entries @@ -317,6 +355,98 @@ def _find_bom_match(bom_data: list, mfr_name: str, mpn: str) -> dict | None: return max(candidates, key=lambda e: e["unit_cost"]) if candidates else None +def _extract_r_vals(s: str) -> tuple: + """Extract (resistance_norm, footprint) from a description string using word boundaries.""" + rm = re.search(r'\b(\d+\.?\d*\s*(?:kohms?|mohms?|k[\s\-]?ohms?|ohms?|k|m|r))\b', s, re.IGNORECASE) + resistance = _norm_elec(rm.group(1)) if rm else "" + fm = re.search(r'\b(0\d{3})\b', s) + footprint = fm.group(1) if fm else "" + return resistance, footprint + + +def _extract_c_vals(s: str) -> tuple: + """Extract (capacitance_norm, voltage_norm, case) from a description string.""" + cm = re.search(r'\b(\d+\.?\d*\s*(?:p|n|u|µ|μ|m)\s*f)\b', s, re.IGNORECASE) + capacitance = _norm_elec(cm.group(1)) if cm else "" + vm = re.search(r'\b(\d+\.?\d*\s*v)\b', s, re.IGNORECASE) + voltage = _norm_elec(vm.group(1)) if vm else "" + fm = re.search(r'\b(0\d{3})\b', s) + case = fm.group(1) if fm else "" + return capacitance, voltage, case + + +def _find_passive_bom_match(bom_data: list, designator: str, nexar_params: dict, nexar_desc: str) -> dict | None: + """Fallback matcher for R/C: extract values from Nexar params/description, match against BOM description.""" + # Take only leading letters from designator e.g. C38 → C, R102 → R + prefix_match = re.match(r'^([a-zA-Z]+)', designator) + prefix = prefix_match.group(1).upper() if prefix_match else "" + if prefix not in ("R", "C"): + return None + + pl = {k.lower(): v.strip() for k, v in nexar_params.items() + if v and str(v).strip() and str(v).strip() != "-"} + + def gp(*keys): + for k in keys: + v = pl.get(k.lower(), "") + if v: + return v + return "" + + pkg_keys = ("package / case", "package/case", "case - imperial", + "case - metric", "supplier device package", "footprint", "case") + + if prefix == "R": + resistance = gp("resistance") + footprint = gp(*pkg_keys) + if not resistance or not footprint: + r_d, fp_d = _extract_r_vals(nexar_desc) + if not resistance: resistance = r_d + if not footprint: footprint = fp_d + if not resistance or not footprint: + return None + + candidates = [] + for e in bom_data: + desc = e.get("description", "") + if not desc or "res" not in desc.lower(): + continue + bom_r, bom_fp = _extract_r_vals(desc) + if not bom_r or not _elec_match(bom_r, resistance): + continue + if not bom_fp or bom_fp.lower() != footprint.lower().strip(): + continue + candidates.append(e) + + else: # C + capacitance = gp("capacitance") + voltage = gp("voltage - rated", "voltage rating", "rated voltage", "voltage") + case = gp(*pkg_keys) + if not capacitance or not case: + c_d, v_d, cs_d = _extract_c_vals(nexar_desc) + if not capacitance: capacitance = c_d + if not voltage: voltage = v_d + if not case: case = cs_d + if not capacitance or not case: + return None + + candidates = [] + for e in bom_data: + desc = e.get("description", "") + if not desc or "cap" not in desc.lower(): + continue + bom_c, bom_v, bom_case = _extract_c_vals(desc) + if not bom_c or not _elec_match(bom_c, capacitance): + continue + if not bom_case or bom_case.lower() != case.lower().strip(): + continue + if voltage and bom_v and not _elec_match(bom_v, voltage): + continue + candidates.append(e) + + return max(candidates, key=lambda e: e["unit_cost"]) if candidates else None + + MAX_MFR_PARTS = 5 def export_to_xlsx(project_name: str, variant_name: str, schematics: list, bom_data: dict | None) -> str: @@ -370,6 +500,10 @@ def export_to_xlsx(project_name: str, variant_name: str, schematics: list, bom_d match = _find_bom_match(bom_data, mfr_name, mpn) if match: break + if not match: + match = _find_passive_bom_match( + bom_data, node.get("designator") or "", params, comp.get("description") or "" + ) if match: row += [match["manufacturer"], match["mpn"], match["unit_cost"]] else: