Compare commits
10 Commits
ab8d5c0c14
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4281fa2b97 | ||
|
|
7a7a9dfdcc | ||
|
|
aa7f720194 | ||
|
|
5966f2b4b8 | ||
|
|
fdfaa7e059 | ||
|
|
adffdd211c | ||
|
|
5869d2c693 | ||
|
|
0de59dabfc | ||
|
|
bcb2c70e93 | ||
|
|
55235f222b |
445
bom_generator.py
Normal file
445
bom_generator.py
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
"""
|
||||||
|
BOM Generator for KiCad projects
|
||||||
|
Generates fitted, unfitted, top-side, and bottom-side BOMs
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, Alignment, PatternFill
|
||||||
|
import csv
|
||||||
|
|
||||||
|
|
||||||
|
def find_hierarchical_sheets(schematic_file):
|
||||||
|
"""
|
||||||
|
Find all hierarchical sheet files referenced in the schematic
|
||||||
|
Returns list of sheet file paths
|
||||||
|
"""
|
||||||
|
sheet_files = []
|
||||||
|
base_dir = os.path.dirname(schematic_file)
|
||||||
|
|
||||||
|
with open(schematic_file, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
in_sheet = False
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
if stripped.startswith('(sheet'):
|
||||||
|
in_sheet = True
|
||||||
|
elif in_sheet and '(property "Sheetfile"' in line:
|
||||||
|
parts = line.split('"')
|
||||||
|
if len(parts) >= 4:
|
||||||
|
sheet_filename = parts[3]
|
||||||
|
sheet_path = os.path.join(base_dir, sheet_filename)
|
||||||
|
if os.path.exists(sheet_path):
|
||||||
|
sheet_files.append(sheet_path)
|
||||||
|
# Recursively find sheets in the child schematic
|
||||||
|
child_sheets = find_hierarchical_sheets(sheet_path)
|
||||||
|
sheet_files.extend(child_sheets)
|
||||||
|
in_sheet = False
|
||||||
|
|
||||||
|
return sheet_files
|
||||||
|
|
||||||
|
|
||||||
|
def parse_pcb_for_component_sides(pcb_file):
|
||||||
|
"""
|
||||||
|
Parse PCB file to determine which side each component is on
|
||||||
|
Returns dict mapping reference -> 'Top' or 'Bottom'
|
||||||
|
"""
|
||||||
|
component_sides = {}
|
||||||
|
|
||||||
|
if not os.path.exists(pcb_file):
|
||||||
|
return component_sides
|
||||||
|
|
||||||
|
with open(pcb_file, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
in_footprint = False
|
||||||
|
current_ref = None
|
||||||
|
current_layer = None
|
||||||
|
footprint_indent = None
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
indent_level = len(line) - len(line.lstrip())
|
||||||
|
|
||||||
|
# Detect start of footprint
|
||||||
|
if stripped.startswith('(footprint'):
|
||||||
|
in_footprint = True
|
||||||
|
current_ref = None
|
||||||
|
current_layer = None
|
||||||
|
footprint_indent = indent_level
|
||||||
|
|
||||||
|
# Detect end of footprint
|
||||||
|
elif in_footprint and stripped == ')' and indent_level == footprint_indent:
|
||||||
|
# Store the side mapping
|
||||||
|
if current_ref and current_layer:
|
||||||
|
side = 'Top' if current_layer == 'F.Cu' else 'Bottom' if current_layer == 'B.Cu' else '-'
|
||||||
|
component_sides[current_ref] = side
|
||||||
|
in_footprint = False
|
||||||
|
current_ref = None
|
||||||
|
current_layer = None
|
||||||
|
|
||||||
|
# Extract reference
|
||||||
|
elif in_footprint and '(property "Reference"' in line:
|
||||||
|
parts = line.split('"')
|
||||||
|
if len(parts) >= 4:
|
||||||
|
current_ref = parts[3]
|
||||||
|
|
||||||
|
# Extract layer
|
||||||
|
elif in_footprint and '(layer' in stripped:
|
||||||
|
parts = line.split('"')
|
||||||
|
if len(parts) >= 2:
|
||||||
|
current_layer = parts[1]
|
||||||
|
|
||||||
|
return component_sides
|
||||||
|
|
||||||
|
|
||||||
|
def parse_single_schematic(schematic_file, dnp_uuids=None):
|
||||||
|
"""
|
||||||
|
Parse a single schematic file for components
|
||||||
|
"""
|
||||||
|
if dnp_uuids is None:
|
||||||
|
dnp_uuids = []
|
||||||
|
|
||||||
|
components = []
|
||||||
|
|
||||||
|
with open(schematic_file, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
in_symbol = False
|
||||||
|
current_component = {}
|
||||||
|
symbol_start_indent = None
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
indent_level = len(line) - len(line.lstrip())
|
||||||
|
|
||||||
|
# Detect start of symbol
|
||||||
|
if stripped.startswith('(symbol'):
|
||||||
|
in_symbol = True
|
||||||
|
current_component = {
|
||||||
|
'reference': '',
|
||||||
|
'value': '',
|
||||||
|
'footprint': '',
|
||||||
|
'ipn': '',
|
||||||
|
'mpn': '',
|
||||||
|
'manufacturer': '',
|
||||||
|
'description': '',
|
||||||
|
'datasheet': '',
|
||||||
|
'side': '-',
|
||||||
|
'is_dnp': False,
|
||||||
|
'uuid': ''
|
||||||
|
}
|
||||||
|
symbol_start_indent = indent_level
|
||||||
|
|
||||||
|
# Detect end of symbol
|
||||||
|
elif in_symbol and stripped == ')' and indent_level == symbol_start_indent:
|
||||||
|
# Only add if we have a reference (filter out power symbols, etc.)
|
||||||
|
if current_component['reference'] and not current_component['reference'].startswith('#'):
|
||||||
|
# Check if lib_id contains "power:"
|
||||||
|
if 'lib_id' in current_component and 'power:' not in current_component.get('lib_id', ''):
|
||||||
|
components.append(current_component.copy())
|
||||||
|
in_symbol = False
|
||||||
|
current_component = {}
|
||||||
|
|
||||||
|
# Extract lib_id
|
||||||
|
elif in_symbol and '(lib_id' in stripped:
|
||||||
|
parts = line.split('"')
|
||||||
|
if len(parts) >= 2:
|
||||||
|
current_component['lib_id'] = parts[1]
|
||||||
|
|
||||||
|
# Extract UUID (comes after DNP line)
|
||||||
|
elif in_symbol and '(dnp' in stripped:
|
||||||
|
# Look forward for UUID
|
||||||
|
for j in range(i + 1, min(len(lines), i + 5)):
|
||||||
|
if '(uuid' in lines[j]:
|
||||||
|
if '\t(uuid' in lines[j] or ' (uuid' in lines[j]:
|
||||||
|
uuid_parts = lines[j].split('"')
|
||||||
|
if len(uuid_parts) >= 2:
|
||||||
|
current_component['uuid'] = uuid_parts[1]
|
||||||
|
# Check if DNP
|
||||||
|
if current_component['uuid'] in dnp_uuids or '(dnp yes)' in stripped or stripped == '(dnp)':
|
||||||
|
current_component['is_dnp'] = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract properties
|
||||||
|
elif in_symbol and '(property' in line:
|
||||||
|
parts = line.split('"')
|
||||||
|
if len(parts) >= 4:
|
||||||
|
prop_name = parts[1]
|
||||||
|
prop_value = parts[3]
|
||||||
|
|
||||||
|
if prop_name == 'Reference':
|
||||||
|
current_component['reference'] = prop_value
|
||||||
|
elif prop_name == 'Value':
|
||||||
|
current_component['value'] = prop_value
|
||||||
|
elif prop_name == 'Footprint':
|
||||||
|
current_component['footprint'] = prop_value
|
||||||
|
elif prop_name == 'IPN':
|
||||||
|
current_component['ipn'] = prop_value
|
||||||
|
elif prop_name == 'MPN':
|
||||||
|
current_component['mpn'] = prop_value
|
||||||
|
elif prop_name == 'Manufacturer':
|
||||||
|
current_component['manufacturer'] = prop_value
|
||||||
|
elif prop_name == 'Description':
|
||||||
|
current_component['description'] = prop_value
|
||||||
|
elif prop_name == 'Datasheet':
|
||||||
|
current_component['datasheet'] = prop_value
|
||||||
|
|
||||||
|
return components
|
||||||
|
|
||||||
|
|
||||||
|
def parse_schematic_for_bom(schematic_file, dnp_uuids=None, pcb_file=None):
|
||||||
|
"""
|
||||||
|
Parse schematic hierarchy and PCB file to extract component information for BOM generation
|
||||||
|
Traverses all hierarchical sheets and gets side information from PCB
|
||||||
|
|
||||||
|
Returns list of components with properties:
|
||||||
|
- reference: Component reference designator
|
||||||
|
- value: Component value
|
||||||
|
- footprint: Footprint name
|
||||||
|
- ipn: Internal Part Number
|
||||||
|
- mpn: Manufacturer Part Number
|
||||||
|
- manufacturer: Manufacturer name
|
||||||
|
- description: Component description
|
||||||
|
- side: Top/Bottom/- (from PCB layer)
|
||||||
|
- is_dnp: Whether component is DNP (Do Not Place)
|
||||||
|
- uuid: Component UUID
|
||||||
|
"""
|
||||||
|
if dnp_uuids is None:
|
||||||
|
dnp_uuids = []
|
||||||
|
|
||||||
|
# Get component side information from PCB
|
||||||
|
component_sides = {}
|
||||||
|
if pcb_file and os.path.exists(pcb_file):
|
||||||
|
print(f"Parsing PCB for component sides: {pcb_file}")
|
||||||
|
component_sides = parse_pcb_for_component_sides(pcb_file)
|
||||||
|
|
||||||
|
# Parse root schematic
|
||||||
|
print(f"Parsing root schematic: {schematic_file}")
|
||||||
|
components = parse_single_schematic(schematic_file, dnp_uuids)
|
||||||
|
|
||||||
|
# Find and parse hierarchical sheets
|
||||||
|
sheet_files = find_hierarchical_sheets(schematic_file)
|
||||||
|
for sheet_file in sheet_files:
|
||||||
|
print(f"Parsing hierarchical sheet: {sheet_file}")
|
||||||
|
sheet_components = parse_single_schematic(sheet_file, dnp_uuids)
|
||||||
|
components.extend(sheet_components)
|
||||||
|
|
||||||
|
# Apply side information from PCB
|
||||||
|
for comp in components:
|
||||||
|
if comp['reference'] in component_sides:
|
||||||
|
comp['side'] = component_sides[comp['reference']]
|
||||||
|
|
||||||
|
print(f"Total components found: {len(components)}")
|
||||||
|
return components
|
||||||
|
|
||||||
|
|
||||||
|
def group_components_for_bom(components):
|
||||||
|
"""
|
||||||
|
Group components by IPN and side for BOM generation
|
||||||
|
Returns list of grouped components with consolidated references
|
||||||
|
"""
|
||||||
|
# Group by (ipn, side, value, footprint, manufacturer, mpn)
|
||||||
|
groups = defaultdict(list)
|
||||||
|
|
||||||
|
for comp in components:
|
||||||
|
# Create grouping key
|
||||||
|
key = (
|
||||||
|
comp.get('ipn', ''),
|
||||||
|
comp.get('side', '-'),
|
||||||
|
comp.get('value', ''),
|
||||||
|
comp.get('footprint', ''),
|
||||||
|
comp.get('manufacturer', ''),
|
||||||
|
comp.get('mpn', ''),
|
||||||
|
comp.get('description', '')
|
||||||
|
)
|
||||||
|
groups[key].append(comp)
|
||||||
|
|
||||||
|
# Create BOM lines
|
||||||
|
bom_lines = []
|
||||||
|
line_num = 1
|
||||||
|
|
||||||
|
for key, comps in sorted(groups.items()):
|
||||||
|
ipn, side, value, footprint, manufacturer, mpn, description = key
|
||||||
|
|
||||||
|
# Sort references naturally (R1, R2, R10, etc.)
|
||||||
|
refs = sorted([c['reference'] for c in comps], key=lambda x: (x.rstrip('0123456789'), int(''.join(filter(str.isdigit, x)) or 0)))
|
||||||
|
refdes_str = ', '.join(refs)
|
||||||
|
|
||||||
|
# Determine SMD/TH from footprint
|
||||||
|
smd_th = 'SMD' if 'SMD' in footprint.upper() or any(x in footprint for x in ['0402', '0603', '0805', '1206']) else 'TH'
|
||||||
|
|
||||||
|
bom_line = {
|
||||||
|
'line_num': line_num,
|
||||||
|
'refdes': refdes_str,
|
||||||
|
'ipn': ipn if ipn else 'N/A',
|
||||||
|
'qty': len(comps),
|
||||||
|
'description': description,
|
||||||
|
'footprint': footprint,
|
||||||
|
'manufacturer': manufacturer,
|
||||||
|
'mpn': mpn,
|
||||||
|
'notes': '',
|
||||||
|
'smd_th': smd_th,
|
||||||
|
'side': side,
|
||||||
|
'populated': True # Since we're only including fitted parts
|
||||||
|
}
|
||||||
|
|
||||||
|
bom_lines.append(bom_line)
|
||||||
|
line_num += 1
|
||||||
|
|
||||||
|
return bom_lines
|
||||||
|
|
||||||
|
|
||||||
|
def generate_main_bom_xlsx(bom_lines, output_file, project_name, variant_name):
|
||||||
|
"""
|
||||||
|
Generate main BOM in XLSX format matching the reference format
|
||||||
|
"""
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "BOM"
|
||||||
|
|
||||||
|
# Set column widths
|
||||||
|
ws.column_dimensions['A'].width = 5
|
||||||
|
ws.column_dimensions['B'].width = 60
|
||||||
|
ws.column_dimensions['C'].width = 12
|
||||||
|
ws.column_dimensions['D'].width = 6
|
||||||
|
ws.column_dimensions['E'].width = 40
|
||||||
|
ws.column_dimensions['F'].width = 25
|
||||||
|
ws.column_dimensions['G'].width = 15
|
||||||
|
ws.column_dimensions['H'].width = 20
|
||||||
|
ws.column_dimensions['I'].width = 15
|
||||||
|
ws.column_dimensions['J'].width = 8
|
||||||
|
ws.column_dimensions['K'].width = 8
|
||||||
|
ws.column_dimensions['L'].width = 10
|
||||||
|
|
||||||
|
# Add header row (row 2)
|
||||||
|
headers = ['#', 'RefDes', 'GLE P/N', 'Qty', 'Description', 'Footprint',
|
||||||
|
'Mfg 1', 'Mfg 1 P/N', 'Notes', 'SMD/TH', 'Side', 'Populated',
|
||||||
|
'Mfg 2', 'Mfg 2 P/N', 'Mfg 3', 'Mfg 3 P/N', 'Mfg 4', 'Mfg 4 P/N',
|
||||||
|
'Mfg 5', 'Mfg 5 P/N']
|
||||||
|
|
||||||
|
for col_idx, header in enumerate(headers, 1):
|
||||||
|
cell = ws.cell(row=2, column=col_idx)
|
||||||
|
cell.value = header
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
cell.fill = PatternFill(start_color='D3D3D3', end_color='D3D3D3', fill_type='solid')
|
||||||
|
cell.alignment = Alignment(horizontal='left', vertical='center')
|
||||||
|
|
||||||
|
# Add BOM lines
|
||||||
|
for idx, bom_line in enumerate(bom_lines, 3):
|
||||||
|
ws.cell(row=idx, column=1, value=bom_line['line_num'])
|
||||||
|
ws.cell(row=idx, column=2, value=bom_line['refdes'])
|
||||||
|
ws.cell(row=idx, column=3, value=bom_line['ipn'])
|
||||||
|
ws.cell(row=idx, column=4, value=bom_line['qty'])
|
||||||
|
ws.cell(row=idx, column=5, value=bom_line['description'])
|
||||||
|
ws.cell(row=idx, column=6, value=bom_line['footprint'])
|
||||||
|
ws.cell(row=idx, column=7, value=bom_line['manufacturer'])
|
||||||
|
ws.cell(row=idx, column=8, value=bom_line['mpn'])
|
||||||
|
ws.cell(row=idx, column=9, value=bom_line['notes'])
|
||||||
|
ws.cell(row=idx, column=10, value=bom_line['smd_th'])
|
||||||
|
ws.cell(row=idx, column=11, value=bom_line['side'])
|
||||||
|
ws.cell(row=idx, column=12, value=bom_line['populated'])
|
||||||
|
|
||||||
|
wb.save(output_file)
|
||||||
|
print(f"Generated main BOM: {output_file}")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_unfitted_bom_csv(components, output_file, project_name, variant_name):
|
||||||
|
"""
|
||||||
|
Generate unfitted (DNP) parts BOM in CSV format
|
||||||
|
"""
|
||||||
|
# Filter only DNP parts
|
||||||
|
dnp_parts = [c for c in components if c['is_dnp']]
|
||||||
|
|
||||||
|
# Sort by reference
|
||||||
|
dnp_parts.sort(key=lambda x: (x['reference'].rstrip('0123456789'),
|
||||||
|
int(''.join(filter(str.isdigit, x['reference'])) or 0)))
|
||||||
|
|
||||||
|
with open(output_file, 'w', newline='', encoding='utf-8') as csvfile:
|
||||||
|
writer = csv.writer(csvfile, quoting=csv.QUOTE_ALL)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
writer.writerow(['Components Not Populated Report'])
|
||||||
|
writer.writerow(['Variant', variant_name, project_name])
|
||||||
|
writer.writerow(['Assembly', '-', ''])
|
||||||
|
writer.writerow(['----------'])
|
||||||
|
|
||||||
|
# DNP parts
|
||||||
|
for part in dnp_parts:
|
||||||
|
writer.writerow([part['reference'], 'Not populated'])
|
||||||
|
|
||||||
|
writer.writerow(['----------'])
|
||||||
|
|
||||||
|
print(f"Generated unfitted BOM: {output_file}")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_top_bom_xlsx(bom_lines, output_file, project_name, variant_name):
|
||||||
|
"""
|
||||||
|
Generate top-side only BOM in XLSX format
|
||||||
|
"""
|
||||||
|
# Filter only top-side parts
|
||||||
|
top_lines = [line for line in bom_lines if line['side'] == 'Top']
|
||||||
|
generate_main_bom_xlsx(top_lines, output_file, project_name, f"{variant_name} (Top)")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_bottom_bom_xlsx(bom_lines, output_file, project_name, variant_name):
|
||||||
|
"""
|
||||||
|
Generate bottom-side only BOM in XLSX format
|
||||||
|
"""
|
||||||
|
# Filter only bottom-side parts
|
||||||
|
bottom_lines = [line for line in bom_lines if line['side'] == 'Bottom']
|
||||||
|
generate_main_bom_xlsx(bottom_lines, output_file, project_name, f"{variant_name} (Bottom)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
print("Usage: python bom_generator.py <schematic_file> <project_name> <variant_name> [dnp_uuids_json] [pcb_file]")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
schematic_file = sys.argv[1]
|
||||||
|
project_name = sys.argv[2]
|
||||||
|
variant_name = sys.argv[3]
|
||||||
|
|
||||||
|
dnp_uuids = []
|
||||||
|
if len(sys.argv) > 4:
|
||||||
|
import json
|
||||||
|
dnp_uuids = json.loads(sys.argv[4])
|
||||||
|
|
||||||
|
pcb_file = None
|
||||||
|
if len(sys.argv) > 5:
|
||||||
|
pcb_file = sys.argv[5]
|
||||||
|
|
||||||
|
# Parse schematic with hierarchical sheets and PCB side info
|
||||||
|
components = parse_schematic_for_bom(schematic_file, dnp_uuids, pcb_file)
|
||||||
|
|
||||||
|
# Filter fitted parts for main BOM
|
||||||
|
fitted_components = [c for c in components if not c['is_dnp']]
|
||||||
|
|
||||||
|
# Group components
|
||||||
|
bom_lines = group_components_for_bom(fitted_components)
|
||||||
|
|
||||||
|
# Generate output files
|
||||||
|
output_dir = os.path.dirname(schematic_file)
|
||||||
|
base_name = f"{project_name}_{variant_name}"
|
||||||
|
|
||||||
|
# Main BOM
|
||||||
|
generate_main_bom_xlsx(bom_lines, os.path.join(output_dir, f"{base_name}_BOM.xlsx"),
|
||||||
|
project_name, variant_name)
|
||||||
|
|
||||||
|
# Unfitted BOM
|
||||||
|
generate_unfitted_bom_csv(components, os.path.join(output_dir, f"{base_name}_Not_Populated.csv"),
|
||||||
|
project_name, variant_name)
|
||||||
|
|
||||||
|
# Top BOM
|
||||||
|
generate_top_bom_xlsx(bom_lines, os.path.join(output_dir, f"{base_name}_BOM_Top.xlsx"),
|
||||||
|
project_name, variant_name)
|
||||||
|
|
||||||
|
# Bottom BOM
|
||||||
|
generate_bottom_bom_xlsx(bom_lines, os.path.join(output_dir, f"{base_name}_BOM_Bottom.xlsx"),
|
||||||
|
project_name, variant_name)
|
||||||
|
|
||||||
|
print(f"BOM generation complete for variant: {variant_name}")
|
||||||
1364
templates/index.html
1364
templates/index.html
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user