This commit is contained in:
david rice
2026-04-15 12:31:17 +01:00
parent cfb9e256b9
commit d93288c906
3 changed files with 182 additions and 42 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.env
.venv/
__pycache__/
output/

13
.vscode/launch.json vendored Normal file
View File

@@ -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"
}
]
}

View File

@@ -97,6 +97,11 @@ query GetSheetComponents($projectId: ID!, $cursor: String) {
} }
nodes { nodes {
designator designator
description
parameters {
name
value
}
component { component {
name name
description description
@@ -254,26 +259,44 @@ def normalise(s: str) -> str:
_MFR_ALIASES = { _MFR_ALIASES = {
"ti": "texasinstruments", "ti": "texasinstruments",
"texas": "texasinstruments", "texas": "texasinstruments",
"texasinstruments": "texasinstruments", "texasinstruments": "texasinstruments",
"onsemi": "onsemi", "onsemi": "onsemi",
"onsemiconductor": "onsemi", "onsemiconductor": "onsemi",
"onsemin": "onsemi", "onsemin": "onsemi",
"nxp": "nxp", "fairchild": "onsemi", # acquired by ON Semi 2016
"nxpsemiconductors": "nxp", "fairchildsemiconductor": "onsemi",
"st": "stmicro", "nxp": "nxp",
"stmicro": "stmicro", "nxpsemiconductors": "nxp",
"stmicroelectronics": "stmicro", "nexperia": "nxp", # spun off from NXP
"abracon": "abracon", "st": "stmicro",
"abraconcorporation": "abracon", "stmicro": "stmicro",
"ck": "ck", "stmicroelectronics": "stmicro",
"ckcomponents": "ck", "abracon": "abracon",
"murata": "murata", "abraconcorporation": "abracon",
"muratamfg": "murata", "ck": "ck",
"muratamanufacturing": "murata", "ckcomponents": "ck",
"kingbright": "kingbright", "murata": "murata",
"kingbight": "kingbright", # known Nexar typo "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 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 = openpyxl.Workbook()
wb.remove(wb.active) 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 # Build header row
mfr_headers = [] mfr_headers = []
for n in range(1, MAX_MFR_PARTS + 1): 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: if not match:
match = _find_passive_bom_match(bom_data, designator, params, nexar_desc) 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 --- # --- Diagnostics when no match ---
if not match: if not match:
if not valid_pairs: 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") print(f" NO MATCH [{designator}] {ref}: no manufacturer data in Nexar")
else: else:
tried_mpns = [p for _, p in valid_pairs] 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) ws.append(row)
output_dir = os.path.join(os.path.dirname(__file__), "output") 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() safe_project = re.sub(r'[\\/:*?"<>|]', "_", project_name).strip()
filename = os.path.join(output_dir, f"{safe_project} - {variant_name}.xlsx") filename = os.path.join(output_dir, f"{safe_project} - {variant_name}.xlsx")
wb.save(filename) wb.save(filename)
@@ -639,19 +734,29 @@ def main():
bom_dir = os.path.join(os.path.dirname(__file__), "BOM") 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")) 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(f"\nBOM files available:")
print("-" * 60) print("-" * 60)
print(f" [0] Skip BOM matching — raw parameter export")
for i, name in enumerate(bom_files, start=1): for i, name in enumerate(bom_files, start=1):
print(f" [{i}] {name}") print(f" [{i}] {name}")
print() print()
choice = prompt_choice(f"Select a BOM file (1-{len(bom_files)}): ", len(bom_files)) while True:
selected_bom = os.path.join(bom_dir, bom_files[choice - 1]) try:
print(f"\nSelected BOM: {bom_files[choice - 1]}") 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 --- # --- Fetch schematic sheet list ---
print("\nFetching schematic sheets...") print("\nFetching schematic sheets...")
@@ -717,32 +822,50 @@ def main():
bom_variants = data["desProjectById"]["design"]["workInProgress"].get("variants") or [] 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) 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() fitted_designators = set()
if bom_variant: if bom_variant:
total_instances = 0
for bom_item in (bom_variant.get("bom") or {}).get("bomItems") or []: for bom_item in (bom_variant.get("bom") or {}).get("bomItems") or []:
for instance in bom_item.get("bomItemInstances") or []: for instance in bom_item.get("bomItemInstances") or []:
total_instances += 1
if instance.get("isFitted"): if instance.get("isFitted"):
fitted_designators.add(instance["designator"]) 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 # Filter each sheet's nodes to fitted components only.
for s in schematics: # If no fitted designator data is available, export all components unfiltered.
s["designItems"]["nodes"] = [ if fitted_designators:
n for n in s["designItems"]["nodes"] for s in schematics:
if n.get("designator") in fitted_designators 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).") 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 --- # --- Load BOM cross-reference data ---
bom_data = load_bom_data(selected_bom) if raw_mode:
if bom_data is None: bom_data = None
print("No 'bom' tab found in the selected BOM file — exporting without BOM data.")
else: 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 --- # --- 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}") print(f"\nExported to: {filename}")