This commit is contained in:
Oliver 2016-05-24 08:35:54 +10:00
commit 7f402f9c1f
13 changed files with 231 additions and 88 deletions

View File

@ -2,4 +2,6 @@ import columns
import netlist_reader
import units
import component
import sort
import sort
import preferences
import bom_writer

View File

@ -12,6 +12,14 @@ class ColumnList:
#default columns for groups
COL_GRP_QUANTITY = 'Quantity'
COL_GRP_TOTAL_COST = 'Total Cost' #Total cost based on quantity
COL_GRP_BUILD_QUANTITY = 'Build Quantity'
#generated columns
_COLUMNS_GEN = [
COL_GRP_QUANTITY,
COL_GRP_BUILD_QUANTITY,
]
#default columns
_COLUMNS_DEFAULT = [
@ -23,6 +31,7 @@ class ColumnList:
COL_FP,
COL_FP_LIB,
COL_GRP_QUANTITY,
COL_GRP_BUILD_QUANTITY,
COL_DATASHEET
]
@ -71,7 +80,7 @@ class ColumnList:
def RemoveColumnByName(self, name):
#first check if this is in an immutable colum
if name in self._COLUMNS_DEFAULT:
if name in self._COLUMNS_PROTECTED:
return
#column does not exist, return

View File

@ -182,7 +182,7 @@ class ComponentGroup():
def getField(self, field):
if not field in self.fields.keys(): return ""
if not self.fields[field]: return ""
return str(self.fields[field])
return u''.join((self.fields[field]))
def getCount(self):
return len(self.components)
@ -255,9 +255,12 @@ class ComponentGroup():
#update 'global' fields
self.fields[ColumnList.COL_REFERENCE] = self.getRefs()
q = self.getCount()
self.fields[ColumnList.COL_GRP_QUANTITY] = "{n}{dnf}".format(
n = self.getCount(),
n=q,
dnf = " (DNF)" if not self.isFitted() else "")
self.fields[ColumnList.COL_GRP_BUILD_QUANTITY] = str(q * self.prefs.buildNumber) if self.isFitted() else "0"
self.fields[ColumnList.COL_VALUE] = self.components[0].getValue()
self.fields[ColumnList.COL_PART] = self.components[0].getPartName()
self.fields[ColumnList.COL_PART_LIB] = self.components[0].getLibName()
@ -303,7 +306,18 @@ class ComponentGroup():
#return a dict of the KiCAD data based on the supplied columns
#NOW WITH UNICODE SUPPORT!
def getRow(self, columns):
row = [self.getField(key) for key in columns]
#print(row)
row = []
for key in columns:
val = self.getField(key)
if val is None:
val = ""
else:
val = u'' + val
val = val.encode('utf-8')
row.append(val)
return row

View File

@ -1,3 +1,5 @@
# _*_ coding:latin-1 _*_
import csv
import columns
from component import *
@ -25,6 +27,11 @@ def WriteCSV(filename, groups, net, headings, prefs):
else:
return False
nGroups = len(groups)
nTotal = sum([g.getCount() for g in groups])
nFitted = sum([g.getCount() for g in groups if g.isFitted()])
nBuild = nFitted * prefs.buildNumber
with open(filename, "w") as f:
writer = csv.writer(f, delimiter=delimiter, lineterminator="\n")
@ -43,8 +50,10 @@ def WriteCSV(filename, groups, net, headings, prefs):
row = group.getRow(headings)
if prefs.numberRows:
row = [rowCount] + row
row = [str(rowCount)] + row
#deal with unicode characters
#row = [el.decode('latin-1') for el in row]
writer.writerow(row)
try:
@ -58,8 +67,12 @@ def WriteCSV(filename, groups, net, headings, prefs):
for i in range(5):
writer.writerow([])
writer.writerow(["Component Count:",sum([g.getCount() for g in groups])])
writer.writerow(["Component Groups:",len(groups)])
writer.writerow(["Component Groups:",nGroups])
writer.writerow(["Component Count:",nTotal])
writer.writerow(["Fitted Components:", nFitted])
if prefs.buildNumber > 0:
writer.writerow(["Number of PCBs:",prefs.buildNumber])
writer.writerow(["Total components:", nBuild])
writer.writerow(["Schematic Version:",net.getVersion()])
writer.writerow(["Schematic Date:",net.getSheetDate()])
writer.writerow(["BoM Date:",net.getDate()])

View File

@ -1,3 +1,4 @@
import columns
from component import *
import os
@ -9,7 +10,7 @@ BG_USER = "#E6F9FF"
#return a background color for a given column title
def bgColor(col):
#auto-generated columns
if col == ColumnList.COL_GRP_QUANTITY:
if col in ColumnList._COLUMNS_GEN:
return BG_GEN
#kicad protected columns
elif col in ColumnList._COLUMNS_PROTECTED:
@ -19,7 +20,7 @@ def bgColor(col):
return BG_USER
def link(text):
text = str(text)
for t in ["http","https","ftp","www"]:
if text.startswith(t):
return '<a href="{t}">{t}</a>'.format(t=text)
@ -41,12 +42,21 @@ def WriteHTML(filename, groups, net, headings, prefs):
print("{fn} is not a valid html file".format(fn=filename))
return False
nGroups = len(groups)
nTotal = sum([g.getCount() for g in groups])
nFitted = sum([g.getCount() for g in groups if g.isFitted()])
nBuild = nFitted * prefs.buildNumber
with open(filename,"w") as html:
#header
html.write("<html>\n")
html.write("<head>\n")
html.write('\t<meta charset="ISO-8859-1">\n') #UTF-8 encoding for unicode support
html.write("</head>\n")
html.write("<body>\n")
#PCB info
html.write("<h2>KiBoM PCB Bill of Materials</h2>\n")
html.write('<table border="1">\n')
@ -55,8 +65,12 @@ def WriteHTML(filename, groups, net, headings, prefs):
html.write("<tr><td>Schematic Version</td><td>{version}</td></tr>\n".format(version=net.getVersion()))
html.write("<tr><td>Schematic Date</td><td>{date}</td></tr>\n".format(date=net.getSheetDate()))
html.write("<tr><td>KiCad Version</td><td>{version}</td></tr>\n".format(version=net.getTool()))
html.write("<tr><td>Total Components</td><td>{n}</td></tr>\n".format(n = sum([g.getCount() for g in groups])))
html.write("<tr><td>Component Groups</td><td>{n}</td></tr>\n".format(n=len(groups)))
html.write("<tr><td>Component Groups</td><td>{n}</td></tr>\n".format(n=nGroups))
html.write("<tr><td>Component Count (per PCB)</td><td>{n}</td></tr>\n".format(n=nTotal))
html.write("<tr><td>Fitted Components (per PCB)</td><td>{n}</td></tr>\n".format(n=nFitted))
if prefs.buildNumber > 0:
html.write("<tr><td>Number of PCBs</td><td>{n}</td></tr>\n".format(n=prefs.buildNumber))
html.write("<tr><td>Total Component Count<br>(for {n} PCBs)</td><td>{t}</td></tr>\n".format(n=prefs.buildNumber, t=nBuild))
html.write("</table>\n")
html.write("<br>\n")
html.write("<h2>Component Groups</h2>\n")
@ -93,7 +107,7 @@ def WriteHTML(filename, groups, net, headings, prefs):
html.write("<tr>\n")
if prefs.numberRows:
html.write('\t<td align="center">{n}</td>'.format(n=rowCount))
html.write('\t<td align="center">{n}</td>\n'.format(n=rowCount))
for n, r in enumerate(row):
bg = bgColor(headings[n])

View File

@ -18,12 +18,15 @@ class BomPref:
SECTION_EXCLUDE_PART = "EXCLUDE_COMPONENT_PART"
SECTION_EXCLUDE_DESC = "EXCLUDE_COMPONENT_DESC"
SECTION_ALIASES = "COMPONENT_ALIASES"
SECTION_CONFIGURATIONS = "PCB_CONFIGURATIONS"
OPT_IGNORE_DNF = "ignore_dnf"
OPT_NUMBER_ROWS = "number_rows"
OPT_GROUP_CONN = "group_connectors"
OPT_USE_REGEX = "test_regex"
OPT_COMP_FP = "compare_footprints"
OPT_INC_PRICE = "calculate_price"
OPT_BUILD_NUMBER = 'build_quantity'
#list of columns which we can use regex on
COL_REG_EX = [
@ -46,6 +49,9 @@ class BomPref:
self.groupConnectors = True #group connectors and ignore component value
self.useRegex = True #Test various columns with regex
self.compareFootprints = True #test footprints when comparing components
self.buildNumber = 0
self.verbose = False #by default, is not verbose
self.configurations = [] #list of various configurations
#default reference exclusions
self.excluded_references = [
@ -71,7 +77,9 @@ class BomPref:
["c", "c_small", "cap", "capacitor"],
["r", "r_small", "res", "resistor"],
["sw", "switch"],
["l", "l_small", "inductor"]
["l", "l_small", "inductor"],
["zener","zenersmall"],
["d","diode","d_small"]
]
#dictionary of possible regex expressions for ignoring component row(s)
@ -97,6 +105,13 @@ class BomPref:
def columnToGroup(self, col):
return "REGEXCLUDE_" + col.upper().replace(" ","_")
#check an option within the SECTION_GENERAL group
def checkOption(self, parser, opt, default=False):
if parser.has_option(self.SECTION_GENERAL, opt):
return parser.get(self.SECTION_GENERAL, opt).lower() in ["1","true","yes"]
else:
return default
#read KiBOM preferences from file
def Read(self, file, verbose=False):
file = os.path.abspath(file)
@ -111,16 +126,23 @@ class BomPref:
#read general options
if self.SECTION_GENERAL in cf.sections():
if cf.has_option(self.SECTION_GENERAL, self.OPT_IGNORE_DNF):
self.ignoreDNF = cf.get(self.SECTION_GENERAL, self.OPT_IGNORE_DNF) == "1"
if cf.has_option(self.SECTION_GENERAL, self.OPT_NUMBER_ROWS):
self.numberRows = cf.get(self.SECTION_GENERAL, self.OPT_NUMBER_ROWS) == "1"
if cf.has_option(self.SECTION_GENERAL, self.OPT_GROUP_CONN):
self.groupConnectors = cf.get(self.SECTION_GENERAL, self.OPT_GROUP_CONN) == "1"
if cf.has_option(self.SECTION_GENERAL, self.OPT_USE_REGEX):
self.useRegex = cf.get(self.SECTION_GENERAL, self.OPT_USE_REGEX) == "1"
if cf.has_option(self.SECTION_GENERAL, self.OPT_COMP_FP):
self.compareFootprints = cf.get(self.SECTION_GENERAL, self.OPT_COMP_FP) == "1"
self.ignoreDNF = self.checkOption(cf, self.OPT_IGNORE_DNF, default=True)
self.numberRows = self.checkOption(cf, self.OPT_NUMBER_ROWS, default=True)
self.groupConnectors = self.checkOption(cf, self.OPT_GROUP_CONN, default=True)
self.useRegex = self.checkOption(cf, self.OPT_USE_REGEX, default=True)
self.compareFootprints = self.checkOption(cf, self.OPT_COMP_FP, default=True)
if cf.has_option(self.SECTION_GENERAL, self.OPT_BUILD_NUMBER):
try:
self.buildNumber = int(cf.get(self.SECTION_GENERAL, self.OPT_BUILD_NUMBER))
if self.buildNumber < 1:
self.buildNumber = 0
except:
pass
#read out configurations
if self.SECTION_CONFIGURATIONS in cf.sections():
self.configurations = [i for i in cf.options(self.SECTION_CONFIGURATIONS)]
#read out ignored-rows
if self.SECTION_IGNORE in cf.sections():
@ -136,7 +158,14 @@ class BomPref:
if section in cf.sections():
self.regex[key] = [r for r in cf.options(section)]
#add an option to the SECTION_GENRAL group
def addOption(self, parser, opt, value, comment=None):
if comment:
if not comment.startswith(";"):
comment = "; " + comment
parser.set(self.SECTION_GENERAL, comment)
parser.set(self.SECTION_GENERAL, opt, "1" if value else "0")
#write KiBOM preferences to file
def Write(self, file):
@ -146,16 +175,12 @@ class BomPref:
cf.add_section(self.SECTION_GENERAL)
cf.set(self.SECTION_GENERAL, "; General BoM options here")
cf.set(self.SECTION_GENERAL, "; If '{opt}' option is set to 1, rows that are not to be fitted on the PCB will not be written to the BoM file".format(opt=self.OPT_IGNORE_DNF))
cf.set(self.SECTION_GENERAL, self.OPT_IGNORE_DNF, 1 if self.ignoreDNF else 0)
cf.set(self.SECTION_GENERAL, "; If '{opt}' option is set to 1, each row in the BoM will be prepended with an incrementing row number".format(opt=self.OPT_NUMBER_ROWS))
cf.set(self.SECTION_GENERAL, self.OPT_NUMBER_ROWS, 1 if self.numberRows else 0)
cf.set(self.SECTION_GENERAL, "; If '{opt}' option is set to 1, connectors with the same footprints will be grouped together, independent of the name of the connector".format(opt=self.OPT_GROUP_CONN))
cf.set(self.SECTION_GENERAL, self.OPT_GROUP_CONN, 1 if self.groupConnectors else 0)
cf.set(self.SECTION_GENERAL, "; If '{opt}' option is set to 1, each component group will be tested against a number of regular-expressions (specified, per column, below). If any matches are found, the row is ignored in the output file".format(opt=self.OPT_USE_REGEX))
cf.set(self.SECTION_GENERAL, self.OPT_USE_REGEX, 1 if self.useRegex else 0)
cf.set(self.SECTION_GENERAL, "; If '{opt}' option is set to 1, two components must have the same footprint to be grouped together. If '{opt}' is not set, then footprint comparison is ignored.".format(opt=self.OPT_COMP_FP))
cf.set(self.SECTION_GENERAL, self.OPT_COMP_FP, 1 if self.compareFootprints else 0)
self.addOption(cf, self.OPT_IGNORE_DNF, self.ignoreDNF, comment="If '{opt}' option is set to 1, rows that are not to be fitted on the PCB will not be written to the BoM file".format(opt=self.OPT_IGNORE_DNF))
self.addOption(cf, self.OPT_NUMBER_ROWS, self.numberRows, comment="If '{opt}' option is set to 1, each row in the BoM will be prepended with an incrementing row number".format(opt=self.OPT_NUMBER_ROWS))
self.addOption(cf, self.OPT_GROUP_CONN, self.groupConnectors, comment="If '{opt}' option is set to 1, connectors with the same footprints will be grouped together, independent of the name of the connector".format(opt=self.OPT_GROUP_CONN))
self.addOption(cf, self.OPT_USE_REGEX, self.useRegex, comment="If '{opt}' option is set to 1, each component group will be tested against a number of regular-expressions (specified, per column, below). If any matches are found, the row is ignored in the output file".format(opt=self.OPT_USE_REGEX))
self.addOption(cf, self.OPT_COMP_FP, self.compareFootprints, comment="If '{opt}' option is set to 1, two components must have the same footprint to be grouped together. If '{opt}' is not set, then footprint comparison is ignored.".format(opt=self.OPT_COMP_FP))
self.addOption(cf, self.OPT_BUILD_NUMBER, self.buildNumber, comment="; '{opt}' is the number of boards to build, which is used to calculate total parts quantity. If this is set to zero (0) then it is ignored".format(opt=self.OPT_BUILD_NUMBER))
cf.add_section(self.SECTION_IGNORE)
cf.set(self.SECTION_IGNORE, "; Any column heading that appears here will be excluded from the Generated BoM")
@ -164,6 +189,11 @@ class BomPref:
for i in self.ignore:
cf.set(self.SECTION_IGNORE, i)
cf.add_section(self.SECTION_CONFIGURATIONS)
cf.set(self.SECTION_CONFIGURATION, '; List of PCB configuration parameters')
for i in self.configurations:
cf.set(self.SECTION_CONFIGURATIONS, i)
cf.add_section(self.SECTION_ALIASES)
cf.set(self.SECTION_ALIASES, "; A series of values which are considered to be equivalent for the part name")
cf.set(self.SECTION_ALIASES, "; Each line represents a space-separated list of equivalent component name values")

View File

@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
# _*_ coding:utf-8 _*_
"""

View File

@ -18,19 +18,32 @@ def WriteXML(filename, groups, net, headings, prefs):
if not filename.endswith(".xml"):
return False
xml = ElementTree.Element('KiCAD_BOM', attrib = {
'Schematic_Source' : net.getSource(),
'Schematic_Version' : net.getVersion(),
'Schematic_Date' : net.getSheetDate(),
'BOM_Date' : net.getDate(),
'KiCad_Version' : net.getTool(),
'groups' : str(len(groups)),
'components' : str(sum([group.getCount() for group in groups]))
})
nGroups = len(groups)
nTotal = sum([g.getCount() for g in groups])
nFitted = sum([g.getCount() for g in groups if g.isFitted()])
nBuild = nFitted * prefs.buildNumber
attrib = {}
attrib['Schematic_Source'] = net.getSource()
attrib['Schematic_Version'] = net.getVersion()
attrib['Schematic_Date'] = net.getSheetDate()
attrib['BOM_Date'] = net.getDate()
attrib['KiCad_Version'] = net.getTool()
attrib['Component_Groups'] = str(nGroups)
attrib['Component_Count'] = str(nTotal)
attrib['Fitted_Components'] = str(nFitted)
if prefs.buildNumber > 0:
attrib['Number_of_PCBs'] = str(prefs.buildNumber)
attrib['Total_Components'] = str(nBuild)
xml = ElementTree.Element('KiCAD_BOM', attrib = attrib, encoding='utf-8')
for group in groups:
if prefs.ignoreDNF and not group.isFitted():
continue
row = group.getRow(headings)
attrib = {}
@ -40,13 +53,12 @@ def WriteXML(filename, groups, net, headings, prefs):
h = h.replace('"','')
h = h.replace("'",'')
attrib[h] = row[i]
attrib[h] = str(row[i]).decode('ascii',errors='ignore')
sub = ElementTree.SubElement(xml, "group", attrib=attrib)
with open(filename,"w") as output:
out = ElementTree.tostring(xml, 'utf-8')
out = ElementTree.tostring(xml, encoding="utf-8")
output.write(minidom.parseString(out).toprettyxml(indent="\t"))
return True

View File

@ -6,67 +6,91 @@ import sys
import os
import shutil
import argparse
here = os.path.abspath(os.path.dirname(sys.argv[0]))
sys.path.append(here)
sys.path.append(os.path.join(here,"KiBOM"))
from KiBOM.columns import ColumnList
from KiBOM.netlist_reader import *
from KiBOM.bom_writer import *
from KiBOM.preferences import BomPref
verbose = False
def close(*arg):
print(*arg)
sys.exit(0)
if len(sys.argv) < 2:
close("No input file supplied")
def say(*arg):
if verbose:
print(*arg)
input_file = sys.argv[1].replace("\\",os.path.sep).replace("/",os.path.sep)
parser = argparse.ArgumentParser(description="KiBOM Bill of Materials generator script")
input_file = os.path.abspath(input_file)
parser.add_argument("netlist", help='xml netlist file. Use "%%I" when running from within KiCad')
parser.add_argument("--output", "-o", help='BoM output file name.\nUse "%%O" when running from within KiCad to use the default output name (csv file).\nFor e.g. HTML output, use "%%O.html"\r\nIf output file is unspecified, default output filename (csv format) will be used', default=None)
parser.add_argument("-v", "--verbose", help="Enable verbose output", action='count')
parser.add_argument("--cfg", help="BoM config file (script will try to use 'bom.ini' if not specified here)")
args = parser.parse_args()
input_file = args.netlist
if not input_file.endswith(".xml"):
close("Supplied file is not .xml")
#work out an output file
ext = ".csv"
if len(sys.argv) < 3:
#no output file supplied, assume .csv
output_file = input_file.replace(".xml",".csv")
else:
output_file = sys.argv[2].replace("\\",os.path.sep).replace("/",os.path.sep)
close("{i} is not a valid xml file".format(i=input_file))
valid = False
verbose = args.verbose is not None
for e in [".xml",".csv",".txt",".tsv",".html"]:
if output_file.endswith(e):
valid = True
ext = e
break
if not valid:
output_file += ext
output_file = os.path.abspath(output_file)
print("Input File: " + input_file)
print("Output File: " + output_file)
input_file = os.path.abspath(input_file)
#preferences
ignore = []
ignoreDNF = False
numberRows = True
say("Input:",input_file)
#Look for a '.bom' preference file
pref_file = os.path.join(os.path.dirname(input_file) , "bom.ini")
output_file = args.output
if output_file is None:
output_file = input_file.replace(".xml","_bom.csv")
#enfore a proper extension
valid = False
extensions = [".xml",".csv",".txt",".tsv",".html"]
for e in extensions:
if output_file.endswith(e):
valid = True
break
if not valid:
close("Extension must be one of",extensions)
output_file = os.path.abspath(output_file)
say("Output:",output_file)
#look for a config file!
#bom.ini by default
ini = os.path.abspath(os.path.join(os.path.dirname(input_file), "bom.ini"))
config_file = ini #default value
#user can overwrite with a specific config file
if args.cfg:
config_file = args.cfg
#read preferences from file. If file does not exists, default preferences will be used
pref = BomPref()
pref.Read(pref_file)
#verbosity options
pref.verbose = verbose
if os.path.exists(config_file):
pref.Read(config_file)
say("Config:",config_file)
#write preference file back out (first run will generate a file with default preferences)
pref.Write(pref_file)
if not os.path.exists(ini):
pref.Write(ini)
say("Writing preferences file bom.ini")
#individual components
components = []
@ -90,6 +114,10 @@ for g in groups:
for f in g.fields:
columns.AddColumn(f)
if pref.buildNumber < 1:
columns.RemoveColumn(ColumnList.COL_GRP_BUILD_QUANTITY)
say("Removing:",ColumnList.COL_GRP_BUILD_QUANTITY)
#Finally, write the BoM out to file
result = WriteBoM(output_file, groups, net, columns.columns, pref)

View File

@ -9,6 +9,26 @@ KiBoM intelligently groups components based on multiple factors, and can generat
BoM options are user-configurable in a per-project configuration file.
## Usage
The *KiBOM_CLI* script can be run directly from KiCad or from the command line. For command help, run the script with the *-h* flag e.g.
python KiBOM_CLI.py -h
![alt tag](example/help.png?raw=True "Command Line")
**netlist** The netlist must be provided to the script. When running from KiCAD use "%I"
**--output / -o** If provided, this is the path to the BoM output. If not provided, the script will use the same name as the input file, with the suffix "_bom.csv"
**--cfg** If provided, this is the BOM config file that will be used. If not provided, options will be loaded from "bom.ini"
**--verbose / -v** Enable extra debugging information
To run from KiCad, simply add the same command line in the *Bill of Materials* script window. e.g. to generate a HTML output:
![alt tag](example/html_eg.png?raw=True "HTML Example")
## Features
### Intelligent Component Grouping
@ -107,7 +127,9 @@ Hit the "Generate" button, and the output window should show that the BoM genera
### HTML Output
The output HTML file is generated as follows:
![alt tag](example/html.png?raw=True "HTML")
![alt tag](example/html_eg.png?raw=True "HTML Gen")
![alt tag](example/html.png?raw=True "HTML Output")
Here the components are correctly grouped, with links to datasheets where appropriate, and fields color-coded.

View File

@ -1,3 +1,2 @@
import sys
sys.path.append(".")
import KiBOM
from KiBOM import *

BIN
example/help.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
example/html_eg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB