From d93288c90699fcc6f72e1819aba6a332fce31905 Mon Sep 17 00:00:00 2001 From: david rice Date: Wed, 15 Apr 2026 12:31:17 +0100 Subject: [PATCH] Commit --- .gitignore | 4 + .vscode/launch.json | 13 +++ nexarxclaude.py | 207 +++++++++++++++++++++++++++++++++++--------- 3 files changed, 182 insertions(+), 42 deletions(-) create mode 100644 .gitignore create mode 100644 .vscode/launch.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6d88b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +.venv/ +__pycache__/ +output/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..3c4fb59 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run nexarxclaude", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/nexarxclaude.py", + "console": "integratedTerminal", + "envFile": "${workspaceFolder}/.env" + } + ] +} diff --git a/nexarxclaude.py b/nexarxclaude.py index 33beb5c..c6c3ffc 100644 --- a/nexarxclaude.py +++ b/nexarxclaude.py @@ -97,6 +97,11 @@ query GetSheetComponents($projectId: ID!, $cursor: String) { } nodes { designator + description + parameters { + name + value + } component { name description @@ -254,26 +259,44 @@ def normalise(s: str) -> str: _MFR_ALIASES = { - "ti": "texasinstruments", - "texas": "texasinstruments", - "texasinstruments": "texasinstruments", - "onsemi": "onsemi", - "onsemiconductor": "onsemi", - "onsemin": "onsemi", - "nxp": "nxp", - "nxpsemiconductors": "nxp", - "st": "stmicro", - "stmicro": "stmicro", - "stmicroelectronics": "stmicro", - "abracon": "abracon", - "abraconcorporation": "abracon", - "ck": "ck", - "ckcomponents": "ck", - "murata": "murata", - "muratamfg": "murata", - "muratamanufacturing": "murata", - "kingbright": "kingbright", - "kingbight": "kingbright", # known Nexar typo + "ti": "texasinstruments", + "texas": "texasinstruments", + "texasinstruments": "texasinstruments", + "onsemi": "onsemi", + "onsemiconductor": "onsemi", + "onsemin": "onsemi", + "fairchild": "onsemi", # acquired by ON Semi 2016 + "fairchildsemiconductor": "onsemi", + "nxp": "nxp", + "nxpsemiconductors": "nxp", + "nexperia": "nxp", # spun off from NXP + "st": "stmicro", + "stmicro": "stmicro", + "stmicroelectronics": "stmicro", + "abracon": "abracon", + "abraconcorporation": "abracon", + "ck": "ck", + "ckcomponents": "ck", + "murata": "murata", + "muratamfg": "murata", + "muratamanufacturing": "murata", + "kingbright": "kingbright", + "kingbight": "kingbright", # known Nexar typo + "tdk": "tdk", + "epcos": "tdk", # acquired by TDK + "tdkepcos": "tdk", + "analogdevices": "analogdevices", + "analog": "analogdevices", + "maxim": "analogdevices", # acquired by Analog Devices 2021 + "maximintegrated": "analogdevices", + "teconnectivity": "teconnectivity", + "tyco": "teconnectivity", + "raychem": "teconnectivity", # Raychem is a TE Connectivity brand + "fujitsu": "fujitsu", + "ramxeed": "fujitsu", # RAMXEED distributes Fujitsu memory + "ramxeedfujitsu": "fujitsu", + "taiyoyuden": "taiyoyuden", + "tayoyuden": "taiyoyuden", # common BOM spelling variant } @@ -482,10 +505,71 @@ def _find_passive_bom_match(bom_data: list, designator: str, nexar_params: dict, MAX_MFR_PARTS = 5 -def export_to_xlsx(project_name: str, variant_name: str, schematics: list, bom_data: dict | None) -> str: +def export_to_xlsx(project_name: str, variant_name: str, schematics: list, bom_data: dict | None, raw_mode: bool = False) -> str: wb = openpyxl.Workbook() wb.remove(wb.active) + if raw_mode: + def _get_params(node): + """Merge component-level and instance-level parameters. Instance wins on conflict.""" + comp = node.get("component") or {} + comp_params = {p["name"]: (p.get("value") or "").strip() + for p in (comp.get("details") or {}).get("parameters") or [] + if p.get("name")} + inst_params = {p["name"]: (p.get("value") or "").strip() + for p in (node.get("parameters") or []) + if p.get("name")} + return {**comp_params, **inst_params} + + def _find_param(params: dict, name: str) -> str: + """Case-insensitive parameter lookup.""" + name_lower = name.lower() + for k, v in params.items(): + if k.lower() == name_lower: + return v + return "" + + headers = ["Designator", "Reference", "Description", "Manufacturer 1", "Manufacturer part number 1"] + + col_letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + for sheet in schematics: + sheet_name = sheet["documentName"] or sheet["documentId"] + safe_name = re.sub(r"[\\/*?:\[\]]", "_", sheet_name)[:31] + ws = wb.create_sheet(title=safe_name) + ws.append(headers) + for i in range(len(headers)): + col = col_letters[i] if i < 26 else col_letters[i // 26 - 1] + col_letters[i % 26] + ws.column_dimensions[col].width = 30 if i >= 3 else 16 + + nodes = (sheet.get("designItems") or {}).get("nodes") or [] + for node in sorted(nodes, key=lambda n: n.get("designator") or ""): + comp = node.get("component") or {} + params = _get_params(node) + part_number = _find_param(params, "PartNumber") + if not part_number: + continue # non-BOM item — skip + description = ( + node.get("description") or + comp.get("description") or + _find_param(params, "Description") or + "" + ) + row = [ + node.get("designator") or "", + part_number, + description, + _find_param(params, "Manufacturer 1"), + _find_param(params, "Manufacturer part number 1"), + ] + ws.append(row) + + output_dir = os.path.join(os.path.dirname(__file__), "output") + os.makedirs(output_dir, exist_ok=True) + safe_project = re.sub(r'[\\/:*?"<>|]', "_", project_name).strip() + filename = os.path.join(output_dir, f"{safe_project} - {variant_name} (raw).xlsx") + wb.save(filename) + return filename + # Build header row mfr_headers = [] for n in range(1, MAX_MFR_PARTS + 1): @@ -543,9 +627,19 @@ def export_to_xlsx(project_name: str, variant_name: str, schematics: list, bom_d if not match: match = _find_passive_bom_match(bom_data, designator, params, nexar_desc) + # --- PartNumber fallback (local Altium library) --- + if not match and not valid_pairs: + part_number = params.get("PartNumber", "").strip() + if part_number and part_number != "-": + candidates = [e for e in bom_data if _mpn_match(e["mpn"], part_number)] + match = max(candidates, key=lambda e: e["unit_cost"]) if candidates else None + # --- Diagnostics when no match --- if not match: if not valid_pairs: + if not hasattr(export_to_xlsx, '_local_lib_sample_printed') and params: + export_to_xlsx._local_lib_sample_printed = True + print(f" [Sample local library params for {designator}]: {dict(list(params.items())[:15])}") print(f" NO MATCH [{designator}] {ref}: no manufacturer data in Nexar") else: tried_mpns = [p for _, p in valid_pairs] @@ -574,6 +668,7 @@ def export_to_xlsx(project_name: str, variant_name: str, schematics: list, bom_d ws.append(row) output_dir = os.path.join(os.path.dirname(__file__), "output") + os.makedirs(output_dir, exist_ok=True) safe_project = re.sub(r'[\\/:*?"<>|]', "_", project_name).strip() filename = os.path.join(output_dir, f"{safe_project} - {variant_name}.xlsx") wb.save(filename) @@ -639,19 +734,29 @@ def main(): bom_dir = os.path.join(os.path.dirname(__file__), "BOM") bom_files = sorted(f for f in os.listdir(bom_dir) if f.lower().endswith(".xlsx")) - if not bom_files: - print(f"\nNo xlsx files found in {bom_dir}.") - return - print(f"\nBOM files available:") print("-" * 60) + print(f" [0] Skip BOM matching — raw parameter export") for i, name in enumerate(bom_files, start=1): print(f" [{i}] {name}") print() - choice = prompt_choice(f"Select a BOM file (1-{len(bom_files)}): ", len(bom_files)) - selected_bom = os.path.join(bom_dir, bom_files[choice - 1]) - print(f"\nSelected BOM: {bom_files[choice - 1]}") + while True: + try: + choice = int(input(f"Select a BOM file (0-{len(bom_files)}): ")) + if 0 <= choice <= len(bom_files): + break + print(f" Please enter a number between 0 and {len(bom_files)}.") + except ValueError: + print(" Please enter a valid number.") + + raw_mode = (choice == 0) + if raw_mode: + selected_bom = None + print("\nRaw parameter export selected — BOM matching skipped.") + else: + selected_bom = os.path.join(bom_dir, bom_files[choice - 1]) + print(f"\nSelected BOM: {bom_files[choice - 1]}") # --- Fetch schematic sheet list --- print("\nFetching schematic sheets...") @@ -717,32 +822,50 @@ def main(): bom_variants = data["desProjectById"]["design"]["workInProgress"].get("variants") or [] bom_variant = next((v for v in bom_variants if v["name"] == selected_variant["name"]), None) + if not bom_variant: + available = [v["name"] for v in bom_variants] + print(f" Warning: variant '{selected_variant['name']}' not found in BOM query.") + print(f" Available variant names: {available}") + fitted_designators = set() if bom_variant: + total_instances = 0 for bom_item in (bom_variant.get("bom") or {}).get("bomItems") or []: for instance in bom_item.get("bomItemInstances") or []: + total_instances += 1 if instance.get("isFitted"): fitted_designators.add(instance["designator"]) + if not total_instances: + print(f" Warning: variant found but no BOM instances returned — BOM may not be generated in Altium 365 for this variant.") + elif not fitted_designators: + print(f" Warning: {total_instances} BOM instance(s) found but none have isFitted=True.") - # Filter each sheet's nodes to fitted components only - for s in schematics: - s["designItems"]["nodes"] = [ - n for n in s["designItems"]["nodes"] - if n.get("designator") in fitted_designators - ] - - total_components = sum(len(s["designItems"]["nodes"]) for s in schematics) - print(f"Total: {total_components} fitted component(s) across {len(schematics)} sheet(s).") + # Filter each sheet's nodes to fitted components only. + # If no fitted designator data is available, export all components unfiltered. + if fitted_designators: + for s in schematics: + s["designItems"]["nodes"] = [ + n for n in s["designItems"]["nodes"] + if n.get("designator") in fitted_designators + ] + total_components = sum(len(s["designItems"]["nodes"]) for s in schematics) + print(f"Total: {total_components} fitted component(s) across {len(schematics)} sheet(s).") + else: + total_components = sum(len(s["designItems"]["nodes"]) for s in schematics) + print(f"No fitted/unfitted variant data found — exporting all {total_components} component(s) across {len(schematics)} sheet(s).") # --- Load BOM cross-reference data --- - bom_data = load_bom_data(selected_bom) - if bom_data is None: - print("No 'bom' tab found in the selected BOM file — exporting without BOM data.") + if raw_mode: + bom_data = None else: - print(f"Loaded {len(bom_data)} entries from BOM.") + bom_data = load_bom_data(selected_bom) + if bom_data is None: + print("No 'bom' tab found in the selected BOM file — exporting without BOM data.") + else: + print(f"Loaded {len(bom_data)} entries from BOM.") # --- Export --- - filename = export_to_xlsx(selected_project["name"], selected_variant["name"], schematics, bom_data) + filename = export_to_xlsx(selected_project["name"], selected_variant["name"], schematics, bom_data, raw_mode=raw_mode) print(f"\nExported to: {filename}")