# -*- coding: utf-8 -*- # Copyright (c) 2020-2021 Salvador E. Tropea # Copyright (c) 2020-2021 Instituto Nacional de TecnologĂ­a Industrial # Copyright (c) 2016-2020 Oliver Henry Walters (@SchrodingersGat) # License: MIT # Project: KiBot (formerly KiPlot) # Adapted from: https://github.com/SchrodingersGat/KiBoM # Contributors: Kenny Huynh (@hkennyv) """ All the logic to convert a list of components into the rows and columns used to create the BoM. """ import locale from copy import deepcopy from math import ceil from .units import compare_values, comp_match, get_last_warning from .bom_writer import write_bom from .columnlist import ColumnList from ..misc import DNF, W_FIELDCONF from .. import log logger = log.get_logger() # RV == Resistor Variable or Varistor # RN == Resistor 'N'(Pack) # RT == Thermistor RLC_PREFIX = {'R', 'L', 'C', 'RV', 'RN', 'RT'} def compare_value(c1, c2, cfg): """ Compare the value of two components """ c1_value = c1.value.strip().lower() c2_value = c2.value.strip().lower() # '~' is the same as empty for KiCad if c1_value == '~': c1_value = '' if c2_value == '~': c2_value = '' # Simple string comparison if c1_value == c2_value: return True # Otherwise, perform a more complicated value comparison if compare_values(c1, c2): 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_value = c1.get_field_value(field).lower() c2_value = c2.get_field_value(field).lower() # If blank comparisons are allowed if (c1_value == "" or c2_value == "") and cfg.merge_blank_fields: return True if not cfg.merge_both_blank and c1_value == "" and c2_value == "": # Avoid merging two components with empty field return False return c1_value == c2_value 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 c1.ref == c2.ref # Check if the grouping fields match for i, field in enumerate(cfg.group_fields): # Check if we have a fallback field_alt = cfg.group_fields_fallbacks[i] if field_alt is not None: # Check if we have an empty field c1_value = c1.get_field_value(field) c2_value = c2.get_field_value(field) if c1_value == "" or c2_value == "": # Try with the fallback field c1_value = c1.get_field_value(field_alt) c2_value = c2.get_field_value(field_alt) if c1_value != "" and c2_value != "": # Compare using the fallback field = field_alt # Perform special matches if field == ColumnList.COL_VALUE_L: if not compare_value(c1, c2, cfg): return False # Match part name elif field == ColumnList.COL_PART_L: if not compare_part_name(c1, c2, cfg): return False # Generic match elif not compare_field(c1, c2, field, cfg): return False return True class Joiner: def __init__(self): self.stack = {} def add(self, P, N): if P not in self.stack: self.stack[P] = [((P, N), (P, N))] return stack = self.stack[P] S, E = stack[-1] if N == E[1] + 1: stack[-1] = (S, (P, N)) else: stack.append(((P, N), (P, N))) def flush(self, sep, dash='-'): refstr = u'' c = 0 for pref in sorted(self.stack.keys()): stack = self.stack[pref] for Q in stack: if c != 0: refstr += sep S, E = Q refstr += S[0]+str(S[1]) if S == E: # Only one element c += 1 elif S[1]+1 == E[1]: # Two elements, I think this is better than pretending this is a real range refstr += sep+E[0]+str(E[1]) c += 2 else: # A range refstr += dash+E[0]+str(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 """ 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+c.project in self.refs def add_component(self, c): """ Add a component to the group. Avoid repetition, checks if suitable. Note: repeated components happend when a component contains more than one unit """ if not self.components: self.components.append(c) self.refs[c.ref+c.project] = c elif self.contains_component(c): return elif self.match_component(c): self.components.append(c) self.refs[c.ref+c.project] = c def round_qty(self, qty): if self.cfg.int_qtys: return int(ceil(qty)) int_qty = int(qty) return int_qty if int_qty == qty else qty def get_count(self, project=None): if project is None: # Total components qty = sum(map(lambda c: c.qty, self.components)) else: # Only for the specified project qty = sum(map(lambda c: c.qty if c.project == project else 0, self.components)) return self.round_qty(qty) def get_build_count(self): if not self.is_fitted(): # Not fitted -> 0 return 0 if len(self.cfg.aggregate) == 1: # Just one project qty = sum(map(lambda c: c.qty, self.components))*self.cfg.number else: # Multiple projects, count them using the number of board for each project qty = sum(map(lambda c: self.cfg.qtys[c.project]*c.qty, self.components)) return self.round_qty(qty) def get_sources(self): sources = {} for c in self.components: prj = c.project if self.cfg.source_by_id: prj = self.cfg.source_to_id[prj] if prj in sources: sources[prj] += c.qty else: sources[prj] = c.qty field = '' for prj in sorted(sources.keys()): n = sources[prj] if len(field): field += ' ' field += prj+'('+str(n)+')' return field 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 is_smd(self): return self.components[0].smd def is_tht(self): return self.components[0].tht 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 self.cfg.ref_separator.join([c.ref for c in self.components]) def get_alt_refs(self): """ Alternative list of references using ranges """ refs = '' for sch in self.cfg.aggregate: S = Joiner() for n in self.components: if n.project == sch.name: S.add(n.ref_id+n.ref_prefix, _suffix_to_num(n.ref_suffix)) result = S.flush(self.cfg.ref_separator) if result: if refs: refs += self.cfg.ref_separator refs += result return refs def update_field(self, field, value, ref=None): """ Update a given field, concatenates existing values and informs a collision """ if not value: return field_ori = field field = field.lower() 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: # Config contains variant information, which is different for each component # Part can be one of the defined aliases if field not in self.cfg.no_conflict: logger.warning(W_FIELDCONF + "Field conflict: ({refs}) [{name}] : '{flds}' <- '{fld}' (in {ref})".format( refs=self.get_refs(), name=field, flds=self.fields[field], fld=value, ref=ref)) 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, c.ref) # 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 self.fields[ColumnList.COL_GRP_QUANTITY_L] = str(self.get_count()) self.total = self.get_build_count() self.fields[ColumnList.COL_GRP_BUILD_QUANTITY_L] = str(self.total) self.fields[ColumnList.COL_SOURCE_BOM_L] = self.get_sources() # Group status status = ' ' if not self.is_fitted(): status += '(DNF)' if self.is_fixed(): status += '(DNC)' self.fields[ColumnList.COL_STATUS_L] = status # Component data 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 if not self.fields[ColumnList.COL_DESCRIPTION_L]: 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 = source.get_text(self.get_field) if v: val += v row.append(val) return row def get_value_sort(comp): """ Try to better sort R, L and C components """ res = comp.value_sort if res: value, (mult, mult_s), unit = res if comp.ref_prefix 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 normalize_value(c, decimal_point): if c.value_sort is None: return c.value value, (mult, mult_s), unit = c.value_sort ivalue = int(value) if value == ivalue: value = ivalue elif decimal_point: value = str(value).replace('.', decimal_point) return '{} {}{}'.format(value, mult_s, unit) def compute_multiple_stats(cfg, groups): for sch in cfg.aggregate: sch.comp_total = sch.comp_total_smd = sch.comp_total_tht = 0 sch.comp_fitted = sch.comp_fitted_smd = sch.comp_fitted_tht = 0 sch.comp_build = 0 sch.comp_groups = 0 for g in groups: g_l = g.get_count(sch.name) if g_l: sch.comp_groups = sch.comp_groups+1 sch.comp_total += g_l if g.is_smd(): sch.comp_total_smd += g_l if g.is_tht(): sch.comp_total_tht += g_l if g.is_fitted(): sch.comp_fitted += g_l if g.is_smd(): sch.comp_fitted_smd += g_l if g.is_tht(): sch.comp_fitted_tht += g_l sch.comp_build = sch.comp_fitted*sch.number if cfg.debug_level > 1: logger.debug('Stats for {}: total {} fitted {} build {}'. format(sch.name, sch.comp_total, sch.comp_fitted, sch.comp_build)) def group_components(cfg, components): groups = [] # Iterate through each component, and test whether a group for these already exists for c in components: if not c.included: # Skip components marked as excluded from BoM continue # Cache the value used to sort if c.ref_prefix in RLC_PREFIX and c.value.lower() not in DNF: c.value_sort = comp_match(c.value, c.ref_prefix, c.ref) if c.value_sort is None and (' ' in c.value): # Try with the data before a space value = c.value.split(' ')[0] value_sort = comp_match(value, c.ref_prefix) if value_sort is not None: c.value_sort = value_sort logger.warning(get_last_warning() + "Using `{}` for {} instead".format(value, c.ref)) else: c.value_sort = None # 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 decimal_point = None if cfg.normalize_locale: decimal_point = locale.localeconv()['decimal_point'] if decimal_point == '.': decimal_point = None for g in groups: # Sort the references within each group g.sort_components() # Fill the columns g.update_fields(cfg.use_alt) if cfg.normalize_values: g.fields[ColumnList.COL_VALUE_L] = normalize_value(g.components[0], decimal_point) # 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 = n_total_smd = n_total_tht = 0 n_fitted = n_fitted_smd = n_fitted_tht = 0 n_build = 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 n_total_smd += g_l*g.is_smd() n_total_tht += g_l*g.is_tht() if is_fitted: n_fitted += g_l n_fitted_smd += g_l*g.is_smd() n_fitted_tht += g_l*g.is_tht() n_build += g.total cfg.n_total = n_total cfg.n_total_smd = n_total_smd cfg.n_total_tht = n_total_tht cfg.n_fitted = n_fitted cfg.n_fitted_smd = n_fitted_smd cfg.n_fitted_tht = n_fitted_tht cfg.n_build = n_build if cfg.debug_level > 1: logger.debug('Global stats: total {} fitted {} build {}'.format(n_total, n_fitted, n_build)) # Compute stats for multiple schematics if len(cfg.aggregate) > 1: compute_multiple_stats(cfg, groups) return groups def smd_tht(cfg, tot, smd, tht): if cfg.count_smd_tht: return "{} ({} SMD/ {} THT)".format(tot, smd, tht) return tot def do_bom(file_name, ext, comps, cfg): # Group components according to group_fields groups = group_components(cfg, comps) # Create the BoM logger.debug("Saving BOM File: "+file_name) number = cfg.number cfg.number = sum(map(lambda prj: prj.number, cfg.aggregate)) # Pre-format the total and fitted strings cfg.total_str = smd_tht(cfg, cfg.n_total, cfg.n_total_smd, cfg.n_total_tht) cfg.fitted_str = smd_tht(cfg, cfg.n_fitted, cfg.n_fitted_smd, cfg.n_fitted_tht) if len(cfg.aggregate) > 1: for prj in cfg.aggregate: prj.total_str = smd_tht(cfg, prj.comp_total, prj.comp_total_smd, prj.comp_total_tht) prj.fitted_str = smd_tht(cfg, prj.comp_fitted, prj.comp_fitted_smd, prj.comp_fitted_tht) # Create the BoM write_bom(file_name, ext, groups, cfg.columns, cfg) cfg.number = number