* Added ability to group components by any arbitrary fields (set in bom.ini file)
* User can determine whether blank fields are merged (or not)
* Improved part grouping
* User can set a 'Configuration' field to determine whether a part is loaded or not under certain circumstances
* Cleanup of preferences management
* Added version number
This commit is contained in:
Oliver 2016-07-04 21:50:54 +10:00
parent 75c46504d9
commit 0afa7bb26e
4 changed files with 197 additions and 120 deletions

View File

@ -35,14 +35,6 @@ class Component():
#no match, return False #no match, return False
return False return False
#compare footprint with another component
def compareFootprint(self, other):
return self.getFootprint().lower() == other.getFootprint().lower()
#compare the component library of this part to another part
def compareLibName(self, other):
return self.getLibName().lower() == other.getLibName().lower()
#determine if two parts have the same name #determine if two parts have the same name
def comparePartName(self, other): def comparePartName(self, other):
pn1 = self.getPartName().lower() pn1 = self.getPartName().lower()
@ -58,23 +50,42 @@ class Component():
return False return False
def compareField(self, other, field):
this_field = self.getField(field).lower()
other_field = other.getField(field).lower()
if this_field == other_field: return True
#if blank comparisons are allowed
if self.prefs.mergeBlankFields:
if this_field == "" or other_field == "":
return True
return False
#Equivalency operator is used to determine if two parts are 'equal' #Equivalency operator is used to determine if two parts are 'equal'
def __eq__(self, other): def __eq__(self, other):
"""Equlivalency operator, remember this can be easily overloaded""" """Equlivalency operator, remember this can be easily overloaded"""
valueResult = self.compareValue(other) results = []
#if connector comparison is overridden, set valueResult to True #'fitted' value must be the same for both parts
if self.prefs.groupConnectors: results.append(self.isFitted() == other.isFitted())
if "conn" in self.getDescription().lower():
valueResult = True
if self.prefs.compareFootprints: for c in self.prefs.groups:
fpResult = self.compareFootprint(other) #perform special matches
else: if c.lower() == ColumnList.COL_VALUE.lower():
fpResult = True results.append(self.compareValue(other))
#match part name
elif c.lower() == ColumnList.COL_PART.lower():
results.append(self.comparePartName(other))
return valueResult and fpResult and self.compareLibName(other) and self.comparePartName(other) and self.isFitted() == other.isFitted() #generic match
else:
results.append(self.compareField(other, c))
return all(results)
def setLibPart(self, part): def setLibPart(self, part):
self.libpart = part self.libpart = part
@ -107,7 +118,7 @@ class Component():
def getValue(self): def getValue(self):
return self.element.get("value") return self.element.get("value")
def getField(self, name, libraryToo=True): def getField(self, name, ignoreCase=True, libraryToo=True):
"""Return the value of a field named name. The component is first """Return the value of a field named name. The component is first
checked for the field, and then the components library part is checked checked for the field, and then the components library part is checked
for the field. If the field doesn't exist in either, an empty string is for the field. If the field doesn't exist in either, an empty string is
@ -119,9 +130,39 @@ class Component():
in component itself in component itself
""" """
#special fields
if name.lower() == ColumnList.COL_REFERENCE.lower():
return self.getRef()
if name.lower() == ColumnList.COL_DESCRIPTION.lower():
return self.getDescription()
if name.lower() == ColumnList.COL_DATASHEET.lower():
return self.getDatasheet()
if name.lower() == ColumnList.COL_FP.lower():
return self.getFootprint().split(":")[0]
if name.lower() == ColumnList.COL_FP.lower():
return self.getFootprint().split(":")[1]
if name.lower() == ColumnList.COL_VALUE.lower():
return self.getValue()
if name.lower() == ColumnList.COL_PART.lower():
return self.getPartName()
if name.lower() == ColumnList.COL_PART_LIB.lower():
return self.getLibName()
field = self.element.get("field", "name", name) field = self.element.get("field", "name", name)
if field == "" and libraryToo: if field == "" and libraryToo:
field = self.libpart.getField(name) field = self.libpart.getField(name)
return field return field
def getFieldNames(self): def getFieldNames(self):
@ -143,12 +184,30 @@ class Component():
#determine if a component is FITTED or not #determine if a component is FITTED or not
def isFitted(self): def isFitted(self):
check = [self.getValue().lower(), self.getField("Notes").lower()] check = self.getField(self.prefs.configField).lower()
for item in check: #check the value field first
if any([dnf in item for dnf in DNF]): return False if self.getValue().lower() in DNF or check.lower() in DNF:
return False
return True if check == "":
return True #empty is fitted
opts = check.split(",")
result = True
for opt in opts:
#options that start with '-' are explicitly removed from certain configurations
if opt.startswith('-') and opt[1:].lower() == self.prefs.pcbConfig.lower():
result = False
break
if opt.startswith("+"):
if opt.lower() == self.prefs.pcbConfig.lower():
result = True
#by default, part is fitted
return result
def getFootprint(self, libraryToo=True): def getFootprint(self, libraryToo=True):
ret = self.element.get("footprint") ret = self.element.get("footprint")
@ -180,6 +239,7 @@ class ComponentGroup():
self.prefs = prefs self.prefs = prefs
def getField(self, field): def getField(self, field):
if not field in self.fields.keys(): return "" if not field in self.fields.keys(): return ""
if not self.fields[field]: return "" if not self.fields[field]: return ""
return u''.join((self.fields[field])) return u''.join((self.fields[field]))
@ -192,6 +252,8 @@ class ComponentGroup():
if len(self.components) == 0: return True if len(self.components) == 0: return True
if c == self.components[0]: return True if c == self.components[0]: return True
return False
#test if a given component is already contained in this grop #test if a given component is already contained in this grop
def containsComponent(self, c): def containsComponent(self, c):
if self.matchComponent(c) == False: return False if self.matchComponent(c) == False: return False
@ -260,6 +322,7 @@ class ComponentGroup():
self.fields[ColumnList.COL_GRP_QUANTITY] = "{n}{dnf}".format( self.fields[ColumnList.COL_GRP_QUANTITY] = "{n}{dnf}".format(
n=q, n=q,
dnf = " (DNF)" if not self.isFitted() else "") dnf = " (DNF)" if not self.isFitted() else "")
self.fields[ColumnList.COL_GRP_BUILD_QUANTITY] = str(q * self.prefs.boards) if self.isFitted() else "0" self.fields[ColumnList.COL_GRP_BUILD_QUANTITY] = str(q * self.prefs.boards) if self.isFitted() else "0"
self.fields[ColumnList.COL_VALUE] = self.components[0].getValue() self.fields[ColumnList.COL_VALUE] = self.components[0].getValue()
self.fields[ColumnList.COL_PART] = self.components[0].getPartName() self.fields[ColumnList.COL_PART] = self.components[0].getPartName()
@ -279,6 +342,10 @@ class ComponentGroup():
#retunr False if any match #retunr False if any match
def testRegex(self): def testRegex(self):
return True
#run the excusion
"""
for key in self.prefs.regex.keys(): for key in self.prefs.regex.keys():
reg = self.prefs.regex[key] reg = self.prefs.regex[key]
if not type(reg) in [str, list]: continue #regex must be a string, or a list of strings if not type(reg) in [str, list]: continue #regex must be a string, or a list of strings
@ -303,6 +370,7 @@ class ComponentGroup():
return False return False
return True return True
"""
#return a dict of the KiCAD data based on the supplied columns #return a dict of the KiCAD data based on the supplied columns

View File

@ -12,46 +12,57 @@ class BomPref:
SECTION_IGNORE = "IGNORE_COLUMNS" SECTION_IGNORE = "IGNORE_COLUMNS"
SECTION_GENERAL = "BOM_OPTIONS" SECTION_GENERAL = "BOM_OPTIONS"
SECTION_EXCLUDE_VALUES = "EXCLUDE_COMPONENT_VALUES"
SECTION_EXCLUDE_REFS = "EXCLUDE_COMPONENT_REFS"
SECTION_EXCLUDE_FP = "EXCLUDE_COMPONENT_FP"
SECTION_EXCLUDE_PART = "EXCLUDE_COMPONENT_PART"
SECTION_EXCLUDE_DESC = "EXCLUDE_COMPONENT_DESC"
SECTION_ALIASES = "COMPONENT_ALIASES" SECTION_ALIASES = "COMPONENT_ALIASES"
SECTION_CONFIGURATIONS = "PCB_CONFIGURATIONS" SECTION_GROUPING_FIELDS = "GROUP_FIELDS"
SECTION_REGEXCLUDES = "REGEX_EXCLUDE"
SECTION_REGINCLUDES = "REGEX_INCLUDE"
OPT_IGNORE_DNF = "ignore_dnf" OPT_PCB_CONFIG = "pcb_configuration"
OPT_NUMBER_ROWS = "number_rows" OPT_NUMBER_ROWS = "number_rows"
OPT_GROUP_CONN = "group_connectors" OPT_GROUP_CONN = "group_connectors"
OPT_USE_REGEX = "test_regex" OPT_USE_REGEX = "test_regex"
OPT_COMP_FP = "compare_footprints" OPT_MERGE_BLANK = "merge_blank_fields"
OPT_INC_PRICE = "calculate_price" OPT_IGNORE_DNF = "ignore_dnf"
#list of columns which we can use regex on OPT_CONFIG_FIELD = "configuration_field"
COL_REG_EX = [
ColumnList.COL_REFERENCE,
ColumnList.COL_DESCRIPTION,
ColumnList.COL_VALUE,
ColumnList.COL_FP,
ColumnList.COL_FP_LIB,
ColumnList.COL_PART,
ColumnList.COL_PART_LIB
]
def __init__(self): def __init__(self):
self.ignore = [ self.ignore = [
ColumnList.COL_PART_LIB, ColumnList.COL_PART_LIB,
ColumnList.COL_FP_LIB, ColumnList.COL_FP_LIB,
] #list of headings to ignore in BoM generation ] #list of headings to ignore in BoM generation
self.ignoreDNF = False #ignore rows for do-not-fit parts self.ignoreDNF = True #ignore rows for do-not-fit parts
self.numberRows = True #add row-numbers to BoM output self.numberRows = True #add row-numbers to BoM output
self.groupConnectors = True #group connectors and ignore component value self.groupConnectors = True #group connectors and ignore component value
self.useRegex = True #Test various columns with regex self.useRegex = True #Test various columns with regex
self.compareFootprints = True #test footprints when comparing components
self.boards = 1 self.boards = 1
self.mergeBlankFields = True #blanks fields will be merged when possible
self.hideHeaders = False self.hideHeaders = False
self.verbose = False #by default, is not verbose self.verbose = False #by default, is not verbose
self.configurations = [] #list of various configurations self.configField = "Config" #default field used for part fitting config
self.pcbConfig = "default"
#default fields used to group components
self.groups = [
ColumnList.COL_PART,
ColumnList.COL_PART_LIB,
ColumnList.COL_VALUE,
ColumnList.COL_FP,
ColumnList.COL_FP_LIB,
#user can add custom grouping columns in bom.ini
]
self.regIncludes = [] #none by default
self.regExcludes = [
[ColumnList.COL_REFERENCE,'TP[0-9]'],
[ColumnList.COL_PART,'mount[\s-_]*hole'],
[ColumnList.COL_PART,'solder[\s-_]*bridge'],
[ColumnList.COL_PART,'test[\s-_]*point'],
[ColumnList.COL_FP,'test[\s-_]*point'],
[ColumnList.COL_FP,'mount[\s-_]*hole'],
[ColumnList.COL_FP,'fiducial'],
]
#default component groupings #default component groupings
self.aliases = [ self.aliases = [
@ -63,29 +74,6 @@ class BomPref:
["d","diode","d_small"] ["d","diode","d_small"]
] ]
#dictionary of possible regex expressions for ignoring component row(s)
self.regex = dict.fromkeys(self.COL_REG_EX)
#default regex values
self.regex[ColumnList.COL_REFERENCE] = [
'TP[0-9]+',
]
self.regex[ColumnList.COL_PART] = [
'mounthole',
'scopetest',
'mount_hole',
'solder_bridge',
'test_point',
]
self.regex[ColumnList.COL_FP] = [
'mounthole'
]
def columnToGroup(self, col):
return "REGEXCLUDE_" + col.upper().replace(" ","_")
#check an option within the SECTION_GENERAL group #check an option within the SECTION_GENERAL group
def checkOption(self, parser, opt, default=False): def checkOption(self, parser, opt, default=False):
if parser.has_option(self.SECTION_GENERAL, opt): if parser.has_option(self.SECTION_GENERAL, opt):
@ -102,6 +90,7 @@ class BomPref:
with open(file, 'rb') as configfile: with open(file, 'rb') as configfile:
cf = ConfigParser.RawConfigParser(allow_no_value = True) cf = ConfigParser.RawConfigParser(allow_no_value = True)
cf.optionxform=str
cf.read(file) cf.read(file)
@ -111,11 +100,17 @@ class BomPref:
self.numberRows = self.checkOption(cf, self.OPT_NUMBER_ROWS, 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.groupConnectors = self.checkOption(cf, self.OPT_GROUP_CONN, default=True)
self.useRegex = self.checkOption(cf, self.OPT_USE_REGEX, default=True) self.useRegex = self.checkOption(cf, self.OPT_USE_REGEX, default=True)
self.compareFootprints = self.checkOption(cf, self.OPT_COMP_FP, default=True) self.mergeBlankFields = self.checkOption(cf, self.OPT_MERGE_BLANK, default = True)
#read out configurations if cf.has_option(self.SECTION_GENERAL, self.OPT_PCB_CONFIG):
if self.SECTION_CONFIGURATIONS in cf.sections(): self.pcbConfig = cf.get(self.SECTION_GENERAL, self.OPT_PCB_CONFIG)
self.configurations = [i for i in cf.options(self.SECTION_CONFIGURATIONS)]
if cf.has_option(self.SECTION_GENERAL, self.OPT_CONFIG_FIELD):
self.configField = cf.get(self.SECTION_GENERAL, self.OPT_CONFIG_FIELD)
#read out grouping colums
if self.SECTION_GROUPING_FIELDS in cf.sections():
self.groups = [i for i in cf.options(self.SECTION_GROUPING_FIELDS)]
#read out ignored-rows #read out ignored-rows
if self.SECTION_IGNORE in cf.sections(): if self.SECTION_IGNORE in cf.sections():
@ -125,12 +120,11 @@ class BomPref:
if self.SECTION_ALIASES in cf.sections(): if self.SECTION_ALIASES in cf.sections():
self.aliases = [a.split(" ") for a in cf.options(self.SECTION_ALIASES)] self.aliases = [a.split(" ") for a in cf.options(self.SECTION_ALIASES)]
#read out the regex if self.SECTION_REGEXCLUDES in cf.sections():
for key in self.regex.keys(): pass
section = self.columnToGroup(key)
if section in cf.sections():
self.regex[key] = [r for r in cf.options(section)]
if self.SECTION_REGINCLUDES in cf.sections():
pass
#add an option to the SECTION_GENRAL group #add an option to the SECTION_GENRAL group
def addOption(self, parser, opt, value, comment=None): def addOption(self, parser, opt, value, comment=None):
@ -145,6 +139,7 @@ class BomPref:
file = os.path.abspath(file) file = os.path.abspath(file)
cf = ConfigParser.RawConfigParser(allow_no_value = True) cf = ConfigParser.RawConfigParser(allow_no_value = True)
cf.optionxform=str
cf.add_section(self.SECTION_GENERAL) cf.add_section(self.SECTION_GENERAL)
cf.set(self.SECTION_GENERAL, "; General BoM options here") cf.set(self.SECTION_GENERAL, "; General BoM options here")
@ -152,7 +147,14 @@ class BomPref:
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_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_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_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_MERGE_BLANK, self.mergeBlankFields, comment="If '{opt}' option is set to 1, component groups with blank fields will be merged into the most compatible group, where possible".format(opt=self.OPT_MERGE_BLANK))
cf.set(self.SECTION_GENERAL, '; Field name used to determine if a particular part is to be fitted')
cf.set(self.SECTION_GENERAL, self.OPT_CONFIG_FIELD, self.configField)
cf.set(self.SECTION_GENERAL, "; Configuration string used to determine which components are loaded on a particular board")
cf.set(self.SECTION_GENERAL, '; Configuration string is case-insensitive')
cf.set(self.SECTION_GENERAL, self.OPT_PCB_CONFIG, self.pcbConfig)
cf.add_section(self.SECTION_IGNORE) cf.add_section(self.SECTION_IGNORE)
cf.set(self.SECTION_IGNORE, "; Any column heading that appears here will be excluded from the Generated BoM") cf.set(self.SECTION_IGNORE, "; Any column heading that appears here will be excluded from the Generated BoM")
@ -161,36 +163,40 @@ class BomPref:
for i in self.ignore: for i in self.ignore:
cf.set(self.SECTION_IGNORE, i) cf.set(self.SECTION_IGNORE, i)
cf.add_section(self.SECTION_CONFIGURATIONS) #write the component grouping fields
cf.set(self.SECTION_CONFIGURATIONS, '; List of PCB configuration parameters') cf.add_section(self.SECTION_GROUPING_FIELDS)
for i in self.configurations: cf.set(self.SECTION_GROUPING_FIELDS, '; List of fields used for sorting individual components into groups')
cf.set(self.SECTION_CONFIGURATIONS, i) cf.set(self.SECTION_GROUPING_FIELDS, '; Components which match (comparing *all* fields) will be grouped together')
cf.set(self.SECTION_GROUPING_FIELDS, '; Field names are CASE-SENSITIVE!')
for i in self.groups:
cf.set(self.SECTION_GROUPING_FIELDS, i)
cf.add_section(self.SECTION_ALIASES) 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, "; 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") cf.set(self.SECTION_ALIASES, "; Each line represents a space-separated list of equivalent component name values")
cf.set(self.SECTION_ALIASES, "; e.g. 'c c_small cap' will ensure the equivalent capacitor symbols can be grouped together") cf.set(self.SECTION_ALIASES, "; e.g. 'c c_small cap' will ensure the equivalent capacitor symbols can be grouped together")
cf.set(self.SECTION_ALIASES, '; Aliases are case-insensitive')
for a in self.aliases: for a in self.aliases:
cf.set(self.SECTION_ALIASES, " ".join(a)) cf.set(self.SECTION_ALIASES, " ".join(a))
for col in self.regex.keys(): cf.add_section(self.SECTION_REGINCLUDES)
cf.set(self.SECTION_REGINCLUDES, '; A series of regular expressions used to include parts in the BoM')
cf.set(self.SECTION_REGINCLUDES, '; Column names are case-insensitive')
for i in self.regIncludes:
if not len(i) == 2: continue
reg = self.regex[col] cf.set(self.SECTION_REGINCLUDE, i[0], i[1])
section = self.columnToGroup(col) cf.add_section(self.SECTION_REGEXCLUDES)
cf.add_section(section) cf.set(self.SECTION_REGEXCLUDES, '; A series of regular expressions used to exclude parts from the BoM')
#comments cf.set(self.SECTION_REGINCLUDES, '; Column names are case-insensitive')
cf.set(section, "; A list of regex to compare against the '{col}' column".format(col=col))
cf.set(section, "; If the value in the '{col}' column matches any of these expressions, the row will be excluded from the BoM".format(col=col))
if type(reg) == str:
cf.set(section, reg)
elif type(reg) == list:
for r in reg:
cf.set(section, r)
for i in self.regExcludes:
if not len(i) == 2: continue
cf.set(self.SECTION_REGEXCLUDES, i[0], i[1])
with open(file, 'wb') as configfile: with open(file, 'wb') as configfile:
cf.write(configfile) cf.write(configfile)

1
KiBOM/version.py Normal file
View File

@ -0,0 +1 @@
KIBOM_VERSION = 1.2

View File

@ -32,9 +32,9 @@ parser = argparse.ArgumentParser(description="KiBOM Bill of Materials generator
parser.add_argument("netlist", help='xml netlist file. Use "%%I" when running from within KiCad') parser.add_argument("netlist", help='xml netlist file. Use "%%I" when running from within KiCad')
parser.add_argument("output", default="", 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"') parser.add_argument("output", default="", 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"')
parser.add_argument("-b", "--boards", help="Number of boards to build (default = 1)", type=int, default=1) parser.add_argument("-n", "--number", help="Number of boards to build (default = 1)", type=int, default=1)
parser.add_argument("-v", "--verbose", help="Enable verbose output", action='count') parser.add_argument("-v", "--verbose", help="Enable verbose output", action='count')
parser.add_argument("-n", "--noheader", help="Do not generate file headers; data only.", action='count') parser.add_argument("-r", "--revision", help="Board variant, used to determine which components are output to the BoM", type=str, default=None)
parser.add_argument("--cfg", help="BoM config file (script will try to use 'bom.ini' if not specified here)") parser.add_argument("--cfg", help="BoM config file (script will try to use 'bom.ini' if not specified here)")
args = parser.parse_args() args = parser.parse_args()
@ -62,20 +62,22 @@ if args.cfg:
#read preferences from file. If file does not exists, default preferences will be used #read preferences from file. If file does not exists, default preferences will be used
pref = BomPref() pref = BomPref()
#pass various command-line options through
pref.verbose = verbose
pref.boards = args.boards
if args.noheader:
pref.hideHeaders = True
if os.path.exists(config_file): if os.path.exists(config_file):
pref.Read(config_file) pref.Read(config_file)
say("Config:",config_file) say("Config:",config_file)
#pass various command-line options through
pref.verbose = verbose
pref.boards = args.number
if args.revision is not None:
pref.pcbConfig = args.revision
print("PCB Revision:",args.revision)
#write preference file back out (first run will generate a file with default preferences) #write preference file back out (first run will generate a file with default preferences)
if not os.path.exists(ini): pref.Write(ini)
pref.Write(ini) say("Writing preferences file bom.ini")
say("Writing preferences file bom.ini")
#individual components #individual components
components = [] components = []