KiBot/kiplot/bom/bom.py

465 lines
15 KiB
Python

"""
BoM
This code is adapted from https://github.com/SchrodingersGat/KiBoM by Oliver Henry Walters.
Here is all the logic to convert a list of components into the rows and columns used to create the BoM.
"""
from copy import deepcopy
from .units import compare_values, comp_match
from .bom_writer import write_bom
from .columnlist import ColumnList
from .. import log
logger = log.get_logger(__name__)
# Supported values for "do not fit"
DNF = {
"dnf": 1,
"dnl": 1,
"dnp": 1,
"do not fit": 1,
"do not place": 1,
"do not load": 1,
"nofit": 1,
"nostuff": 1,
"noplace": 1,
"noload": 1,
"not fitted": 1,
"not loaded": 1,
"not placed": 1,
"no stuff": 1,
}
# String matches for marking a component as "do not change" or "fixed"
DNC = {
"dnc": 1,
"do not change": 1,
"no change": 1,
"fixed": 1
}
def compare_value(c1, c2, cfg):
""" Compare the value of two components """
# Simple string comparison
if c1.value.lower() == c2.value.lower():
return True
# Otherwise, perform a more complicated value comparison
if compare_values(c1.value, c2.value):
return True
# Ignore value if both components are connectors
# Note: Is common practice to use the "Value" field of connectors to denote its use.
# In this case the values won't match even when the connectors are equivalent.
if cfg.group_connectors:
if 'connector' in c1.lib.lower() and 'connector' in c2.lib.lower():
return True
# No match, return False
return False
def compare_part_name(c1, c2, cfg):
""" Determine if two parts have the same name, compute aliases """
pn1 = c1.name.lower()
pn2 = c2.name.lower()
# Simple direct match
if pn1 == pn2:
return True
# Compare part aliases e.g. "c" to "c_small"
for alias in cfg.component_aliases:
if pn1 in alias and pn2 in alias:
return True
return False
def compare_field(c1, c2, field, cfg):
c1_field = c1.get_field_value(field).lower()
c2_field = c2.get_field_value(field).lower()
# If blank comparisons are allowed
if (c1_field == "" or c2_field == "") and not cfg.merge_blank_fields:
return False
return c1_field == c2_field
def compare_components(c1, c2, cfg):
""" Determine if two parts are 'equal' """
# 'fitted' value must be the same for both parts
if c1.fitted != c2.fitted:
return False
# 'fixed' value must be the same for both parts
if c1.fixed != c2.fixed:
return False
# Do not group components
if len(cfg.group_fields) == 0:
return False
# Check if the grouping fileds match
for c in cfg.group_fields:
# Perform special matches
if c == ColumnList.COL_VALUE_L:
if not compare_value(c1, c2, cfg):
return False
# Match part name
elif c == ColumnList.COL_PART_L:
if not compare_part_name(c1, c2, cfg):
return False
# Generic match
elif not compare_field(c1, c2, c, cfg):
return False
return True
class Joiner:
def __init__(self):
self.stack = []
def add(self, P, N):
if not self.stack:
self.stack.append(((P, N), (P, N)))
return
S, E = self.stack[-1]
if N == E[1] + 1:
self.stack[-1] = (S, (P, N))
else:
self.stack.append(((P, N), (P, N)))
def flush(self, sep, N=None, dash='-'):
refstr = u''
c = 0
for Q in self.stack:
if bool(N) and c != 0 and c % N == 0:
refstr += u'\n'
elif c != 0:
refstr += sep
S, E = Q
if S == E:
refstr += "%s%d" % S
c += 1
else:
# Do we have space?
if bool(N) and (c + 1) % N == 0:
refstr += u'\n'
c += 1
refstr += "%s%d%s%s%d" % (S[0], S[1], dash, E[0], E[1])
c += 2
return refstr
def _suffix_to_num(suffix):
return 0 if suffix == '?' else int(suffix)
class ComponentGroup(object):
""" A row in the BoM """
def __init__(self, cfg):
""" Initialize the group with no components, and default fields """
self.components = []
self.refs = {}
self.cfg = cfg
# Columns loaded from KiCad
self.fields = {c.lower(): None for c in ColumnList.COLUMNS_DEFAULT}
self.field_names = deepcopy(ColumnList.COLUMNS_DEFAULT)
def match_component(self, c):
""" Test if a given component fits in this group """
if not self.components:
return True
return compare_components(c, self.components[0], self.cfg)
def contains_component(self, c):
""" Test if a given component is already contained in this group """
return c.ref in self.refs
def add_component(self, c):
""" Add a component to the group.
Avoid repetition, checks if suitable """
if not self.components:
self.components.append(c)
self.refs[c.ref] = c
elif self.contains_component(c):
return
elif self.match_component(c):
self.components.append(c)
self.refs[c.ref] = c
def get_count(self):
return len(self.components)
def is_fitted(self):
# compare_components ensures all has the same status
return self.components[0].fitted
def is_fixed(self):
# compare_components ensures all has the same status
return self.components[0].fixed
def get_field(self, field):
field = field.lower()
if field not in self.fields or not self.fields[field]:
return ""
return self.fields[field]
def sort_components(self):
""" Sort the components in correct order (by reference).
First priority is the prefix, second the number (as integer) """
self.components = sorted(self.components, key=lambda c: [c.ref_prefix, _suffix_to_num(c.ref_suffix)])
def get_refs(self):
""" Return a list of the components """
return " ".join([c.ref for c in self.components])
def get_alt_refs(self):
""" Alternative list of references using ranges """
S = Joiner()
for n in self.components:
P, N = (n.ref_prefix, _suffix_to_num(n.ref_suffix))
S.add(P, N)
return S.flush(' ')
def update_field(self, field, value):
""" Update a given field, concatenates existing values and informs a collision """
if not value:
return
field_ori = field
field = field.lower()
# Exclude the ones we handle in special ways
if field in ColumnList.COLUMNS_PROTECTED_L:
return
if (field not in self.fields) or (not self.fields[field]):
self.fields[field] = value
self.field_names.append(field_ori)
elif value.lower() in self.fields[field].lower():
return
else:
if field != self.cfg.fit_field:
logger.warning("Field conflict: ({refs}) [{name}] : '{flds}' <- '{fld}'".format(
refs=self.get_refs(),
name=field,
flds=self.fields[field],
fld=value))
self.fields[field] += " " + value
def update_fields(self, usealt=False):
for c in self.components:
for f, v in c.get_user_fields():
self.update_field(f, v)
# Update 'global' fields
if usealt:
self.fields[ColumnList.COL_REFERENCE_L] = self.get_alt_refs()
else:
self.fields[ColumnList.COL_REFERENCE_L] = self.get_refs()
# Quantity
q = self.get_count()
self.fields[ColumnList.COL_GRP_QUANTITY_L] = "{n}{dnf}{dnc}".format(
n=q,
dnf=" (DNF)" if not self.is_fitted() else "",
dnc=" (DNC)" if self.is_fixed() else "")
self.fields[ColumnList.COL_GRP_BUILD_QUANTITY_L] = str(q * self.cfg.number) if self.is_fitted() else "0"
comp = self.components[0]
self.fields[ColumnList.COL_VALUE_L] = comp.value
self.fields[ColumnList.COL_PART_L] = comp.name
self.fields[ColumnList.COL_PART_LIB_L] = comp.lib
self.fields[ColumnList.COL_DATASHEET_L] = comp.datasheet
self.fields[ColumnList.COL_FP_L] = comp.footprint
self.fields[ColumnList.COL_FP_LIB_L] = comp.footprint_lib
self.fields[ColumnList.COL_SHEETPATH_L] = comp.sheet_path_h
self.fields[ColumnList.COL_DESCRIPTION_L] = comp.desc
def get_row(self, columns):
""" Return a dict of the KiCad data based on the supplied columns """
row = []
for key in columns:
val = self.get_field(key)
# Join fields (appending to current value)
for join_l in self.cfg.join:
# Each list is "target, source..." so we need at least 2 elements
elements = len(join_l)
target = join_l[0]
if elements > 1 and target == key:
# Append data from the other fields
for source in join_l[1:]:
v = self.get_field(source)
if v:
val = val + ' ' + v
if val is None:
val = ""
row.append(val)
return row
def test_reg_exclude(cfg, c):
""" Test if this part should be included, based on any regex expressions provided in the preferences """
for reg in cfg.exclude_any:
field_value = c.get_field_value(reg.column)
if reg.regex.search(field_value):
if cfg.debug_level > 1:
logger.debug("Excluding '{ref}': Field '{field}' ({value}) matched '{re}'".format(
ref=c.ref, field=reg.column, value=field_value, re=reg.regex))
# Found a match
return True
# Default, could not find any matches
return False
def test_reg_include(cfg, c):
""" Reject components that doesn't match the provided regex.
So we include only the components that matches any of the regexs. """
if not cfg.include_only: # Nothing to match against, means include all
return True
for reg in cfg.include_only:
field_value = c.get_field_value(reg.column)
if reg.regex.search(field_value):
if cfg.debug_level > 1:
logger.debug("Including '{ref}': Field '{field}' ({value}) matched '{re}'".format(
ref=c.ref, field=reg.column, value=field_value, re=reg.regex))
# Found a match
return True
# Default, could not find a match
return False
def get_value_sort(comp):
""" Try to better sort R, L and C components """
pref = comp.ref_prefix
if pref in 'RLC' or pref == 'RV':
res = comp_match(comp.value)
if res:
value, mult, unit = res
if pref in "CL":
# fempto Farads
value = "{0:15d}".format(int(value * 1e15 * mult + 0.1))
else:
# milli Ohms
value = "{0:15d}".format(int(value * 1000 * mult + 0.1))
return value
return comp.value
def group_components(cfg, components):
groups = []
# Iterate through each component, and test whether a group for these already exists
for c in components:
if cfg.test_regex:
# Skip components if they do not meet regex requirements
if not test_reg_include(cfg, c):
continue
if test_reg_exclude(cfg, c):
continue
# Try to add the component to an existing group
found = False
for g in groups:
if g.match_component(c):
g.add_component(c)
found = True
break
if not found:
# Create a new group
g = ComponentGroup(cfg)
g.add_component(c)
groups.append(g)
# Now unify the data from the components of each group
for g in groups:
# Sort the references within each group
g.sort_components()
# Fill the columns
g.update_fields(cfg.use_alt)
# Sort the groups
# First priority is the Type of component (e.g. R?, U?, L?)
groups = sorted(groups, key=lambda g: [g.components[0].ref_prefix, get_value_sort(g.components[0])])
# Enumerate the groups and compute stats
n_total = 0
n_fitted = 0
c = 1
dnf = 1
cfg.n_groups = len(groups)
for g in groups:
is_fitted = g.is_fitted()
if cfg.ignore_dnf and not is_fitted:
g.update_field('Row', str(dnf))
dnf += 1
else:
g.update_field('Row', str(c))
c += 1
# Stats
g_l = g.get_count()
n_total += g_l
if is_fitted:
n_fitted += g_l
cfg.n_total = n_total
cfg.n_fitted = n_fitted
cfg.n_build = n_fitted * cfg.number
return groups
def comp_is_fixed(value, config, variants):
""" Determine if a component is FIXED or not.
Fixed components shouldn't be replaced without express authorization.
value: component value (lowercase).
config: content of the 'Config' field (lowercase).
variants: list of variants to match. """
# Check the value field first
if value in DNC:
return True
# Empty is not fixed
if not config:
return False
# Also support space separated list (simple cases)
opts = config.split(" ")
for opt in opts:
if opt in DNC:
return True
# Normal separator is ","
opts = config.split(",")
for opt in opts:
if opt in DNC:
return True
return False
def comp_is_fitted(value, config, variants):
""" Determine if a component will be or not.
value: component value (lowercase).
config: content of the 'Config' field (lowercase).
variants: list of variants to match. """
# Check the value field first
if value in DNF:
return False
# Empty value means part is fitted
if not config:
return True
# Also support space separated list (simple cases)
opts = config.split(" ")
for opt in opts:
if opt in DNF:
return False
# Variants logic
opts = config.split(",")
for opt in opts:
opt = opt.strip()
# Any option containing a DNF is not fitted
if opt in DNF:
return False
# Options that start with '-' are explicitly removed from certain configurations
if opt.startswith("-") and opt[1:] in variants:
return False
# Options that start with '+' are fitted only for certain configurations
if opt.startswith("+") and opt[1:] not in variants:
return False
return True
def do_bom(file_name, ext, comps, cfg):
# Solve `fixed` and `fitted` attributes for all components
variants = cfg.variant
f_config = cfg.fit_field
for c in comps:
value = c.value.lower()
config = c.get_field_value(f_config).lower()
c.fitted = comp_is_fitted(value, config, variants)
c.fixed = comp_is_fixed(value, config, variants)
# Group components according to group_fields
groups = group_components(cfg, comps)
# Give a name to empty variant
if not variants:
cfg.variant = ['default']
# Create the BoM
logger.debug("Saving BOM File: "+file_name)
write_bom(file_name, ext, groups, cfg.columns, cfg)