Now you can consolidate more than one project in one BoM.

The basic idea comes from pimpmykicadbom by Anton Savov (@antto)
This commit is contained in:
Salvador E. Tropea 2021-01-21 14:43:47 -03:00
parent 66e342e36d
commit 15474ae4d7
18 changed files with 472 additions and 142 deletions

View File

@ -8,8 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- The multipart id to references of multipart components others than part 1.
- Internal BoM: `no_conflict` option to exclude fields from conflict detection.
- Internal BoM: HTML tables can be sorted selecting a column (Java Script).
- Internal BoM:
- `no_conflict` option to exclude fields from conflict detection.
- HTML tables can be sorted selecting a column (Java Script).
- You can consolidate more than one project in one BoM.
- Support for KICAD_CONFIG_HOME defined from inside KiCad.
- Now layers can be selected using the default KiCad names.
- More control over the name of the drill and gerber files.

View File

@ -570,6 +570,12 @@ Next time you need this list just use an alias, like this:
- `name`: [string=''] Used to identify this particular output definition.
- `options`: [dict] Options for the `bom` output.
* Valid keys:
- `aggregate`: [list(dict)] Add components from other projects.
* Valid keys:
- `file`: [string=''] Name of the schematic to aggregate.
- `name`: [string=''] Name to identify this source. If empty we use the name of the schematic.
- `number`: [number=1] Number of boards to build (components multiplier). Use negative to substract.
- `ref_id`: [string=''] A prefix to add to all the references from this project.
- `columns`: [list(dict)|list(string)] List of columns to display.
Can be just the name of the field.
* Valid keys:
@ -628,7 +634,9 @@ Next time you need this list just use an alias, like this:
- `normalize_values`: [boolean=false] Try to normalize the R, L and C values, producing uniform units and prefixes.
- `number`: [number=1] Number of boards to build (components multiplier).
- `output`: [string='%f-%i%v.%x'] filename for the output (%i=bom). Affected by global options.
- `ref_id`: [string=''] A prefix to add to all the references from this project. Used for multiple projects.
- `ref_separator`: [string=' '] Separator used for the list of references.
- `source_by_id`: [boolean=false] Generate the `Source BoM` column using the reference ID instead of the project name.
- `use_alt`: [boolean=false] Print grouped references in the alternate compressed style eg: R1-R7,R18.
- `variant`: [string=''] Board variant, used to determine which components
are output to the BoM..
@ -1846,9 +1854,11 @@ The internal list of rotations is:
- **KiBoM**: Oliver Henry Walters (@SchrodingersGat)
- **Interactive HTML BoM**: @qu1ck
- **PcbDraw**: Jan Mrázek (@yaqwsx)
- **KiCad Gerber Zipper**: @g200kg
- **Contributors**:
- **Error filters ideas**: Leandro Heck (@leoheck)
- **GitHub Actions Integration/SVG output**: @nerdyscout
- **Sources of inspiration and good ideas**:
- **KiCad Gerber Zipper**: @g200kg
- **pimpmykicadbom **: Anton Savov (@antto)
- **Others**:
- **Robot in the logo**: Christian Plaza (from pixabay)

View File

@ -942,9 +942,11 @@ The internal list of rotations is:
- **KiBoM**: Oliver Henry Walters (@SchrodingersGat)
- **Interactive HTML BoM**: @qu1ck
- **PcbDraw**: Jan Mrázek (@yaqwsx)
- **KiCad Gerber Zipper**: @g200kg
- **Contributors**:
- **Error filters ideas**: Leandro Heck (@leoheck)
- **GitHub Actions Integration/SVG output**: @nerdyscout
- **Sources of inspiration and good ideas**:
- **KiCad Gerber Zipper**: @g200kg
- **pimpmykicadbom **: Anton Savov (@antto)
- **Others**:
- **Robot in the logo**: Christian Plaza (from pixabay)

View File

@ -39,6 +39,16 @@ outputs:
type: 'bom'
dir: 'Example/bom_dir'
options:
# [list(dict)] Add components from other projects
aggregate:
# [string=''] Name of the schematic to aggregate
- file: ''
# [string=''] Name to identify this source. If empty we use the name of the schematic
name: ''
# [number=1] Number of boards to build (components multiplier). Use negative to substract
number: 1
# [string=''] A prefix to add to all the references from this project
ref_id: ''
# [list(dict)|list(string)] List of columns to display.
# Can be just the name of the field
columns:
@ -129,8 +139,12 @@ outputs:
number: 1
# [string='%f-%i%v.%x'] filename for the output (%i=bom). Affected by global options
output: '%f-%i%v.%x'
# [string=''] A prefix to add to all the references from this project. Used for multiple projects
ref_id: ''
# [string=' '] Separator used for the list of references
ref_separator: ' '
# [boolean=false] Generate the `Source BoM` column using the reference ID instead of the project name
source_by_id: false
# [boolean=false] Print grouped references in the alternate compressed style eg: R1-R7,R18
use_alt: false
# [string=''] Board variant, used to determine which components

View File

@ -166,8 +166,38 @@ class ComponentGroup(object):
self.components.append(c)
self.refs[c.ref] = c
def get_count(self):
return len(self.components)
def get_count(self, project=None):
if project is None:
# Total components
return len(self.components)
# Only for the specified project
return sum(map(lambda c: c.project == project, self.components))
def get_build_count(self):
if not self.is_fitted():
# Not fitted -> 0
return 0
if len(self.cfg.aggregate) == 1:
# Just one project
return len(self.components)*self.cfg.number
# Multiple projects, count them using the number of board for each project
return sum(map(lambda c: self.cfg.qtys[c.project], self.components))
def get_sources(self):
sources = {}
for c in self.components:
if c.project in sources:
sources[c.project] += 1
else:
sources[c.project] = 1
field = ''
for prj, n in sources.items():
if len(field):
field += ' '
if self.cfg.source_by_id:
prj = self.cfg.source_to_id[prj]
field += prj+'('+str(n)+')'
return field
def is_fitted(self):
# compare_components ensures all has the same status
@ -196,7 +226,7 @@ class ComponentGroup(object):
""" Alternative list of references using ranges """
S = Joiner()
for n in self.components:
P, N = (n.ref_prefix, _suffix_to_num(n.ref_suffix))
P, N = (n.ref_id+n.ref_prefix, _suffix_to_num(n.ref_suffix))
S.add(P, N)
return S.flush(self.cfg.ref_separator)
@ -232,9 +262,10 @@ class ComponentGroup(object):
else:
self.fields[ColumnList.COL_REFERENCE_L] = self.get_refs()
# Quantity
q = self.get_count()
self.fields[ColumnList.COL_GRP_QUANTITY_L] = str(q)
self.fields[ColumnList.COL_GRP_BUILD_QUANTITY_L] = str(q * self.cfg.number) if self.is_fitted() else "0"
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():
@ -301,6 +332,25 @@ def normalize_value(c, decimal_point):
return '{} {}{}'.format(value, mult_s, unit)
def compute_multiple_stats(cfg, groups):
for sch in cfg.aggregate:
sch.comp_total = 0
sch.comp_fitted = 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_fitted():
sch.comp_fitted += 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
@ -343,6 +393,7 @@ def group_components(cfg, components):
# Enumerate the groups and compute stats
n_total = 0
n_fitted = 0
n_build = 0
c = 1
dnf = 1
cfg.n_groups = len(groups)
@ -359,9 +410,15 @@ def group_components(cfg, components):
n_total += g_l
if is_fitted:
n_fitted += g_l
n_build += g.total
cfg.n_total = n_total
cfg.n_fitted = n_fitted
cfg.n_build = n_fitted * cfg.number
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
@ -370,4 +427,7 @@ def do_bom(file_name, ext, comps, cfg):
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))
write_bom(file_name, ext, groups, cfg.columns, cfg)
cfg.number = number

View File

@ -49,6 +49,8 @@ class ColumnList:
COL_GRP_QUANTITY_L = COL_GRP_QUANTITY.lower()
COL_GRP_BUILD_QUANTITY = 'Build Quantity'
COL_GRP_BUILD_QUANTITY_L = COL_GRP_BUILD_QUANTITY.lower()
COL_SOURCE_BOM = 'Source BoM'
COL_SOURCE_BOM_L = COL_SOURCE_BOM.lower()
# Generated columns
COLUMNS_GEN_L = {
@ -56,6 +58,7 @@ class ColumnList:
COL_GRP_BUILD_QUANTITY_L: 1,
COL_ROW_NUMBER_L: 1,
COL_STATUS_L: 1,
COL_SOURCE_BOM_L: 1,
}
# Default columns
@ -72,7 +75,8 @@ class ColumnList:
COL_GRP_BUILD_QUANTITY,
COL_STATUS,
COL_DATASHEET,
COL_SHEETPATH
COL_SHEETPATH,
COL_SOURCE_BOM,
]
# Default columns

View File

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020 Salvador E. Tropea
# Copyright (c) 2020 Instituto Nacional de Tecnología Industrial
# 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)
@ -11,6 +11,58 @@ CSV Writer: Generates a CSV, TSV or TXT BoM file.
import csv
def write_stats(writer, cfg):
if len(cfg.aggregate) == 1:
# Only one project
if not cfg.csv.hide_pcb_info:
prj = cfg.aggregate[0]
writer.writerow(["Project info:"])
writer.writerow(["Schematic:", prj.name])
writer.writerow(["Variant:", cfg.variant.name])
writer.writerow(["Revision:", prj.sch.revision])
writer.writerow(["Date:", prj.sch.date])
writer.writerow(["KiCad Version:", cfg.kicad_version])
if not cfg.csv.hide_stats_info:
writer.writerow(["Statistics:"])
writer.writerow(["Component Groups:", cfg.n_groups])
writer.writerow(["Component Count:", cfg.n_total])
writer.writerow(["Fitted Components:", cfg.n_fitted])
writer.writerow(["Number of PCBs:", cfg.number])
writer.writerow(["Total Components:", cfg.n_build])
else:
# Multiple projects
if not cfg.csv.hide_pcb_info:
prj = cfg.aggregate[0]
writer.writerow(["Project info:"])
writer.writerow(["Variant:", cfg.variant.name])
writer.writerow(["KiCad Version:", cfg.kicad_version])
if not cfg.csv.hide_stats_info:
writer.writerow(["Global statistics:"])
writer.writerow(["Component Groups:", cfg.n_groups])
writer.writerow(["Component Count:", cfg.n_total])
writer.writerow(["Fitted Components:", cfg.n_fitted])
writer.writerow(["Number of PCBs:", cfg.number])
writer.writerow(["Total Components:", cfg.n_build])
# Individual stats
for prj in cfg.aggregate:
if not cfg.csv.hide_pcb_info:
writer.writerow(["Project info:", prj.sch.title])
writer.writerow(["Schematic:", prj.name])
writer.writerow(["Revision:", prj.sch.revision])
writer.writerow(["Date:", prj.sch.date])
if prj.sch.company:
writer.writerow(["Company:", prj.sch.company])
if prj.ref_id:
writer.writerow(["ID", prj.ref_id])
if not cfg.csv.hide_stats_info:
writer.writerow(["Statistics:", prj.sch.title])
writer.writerow(["Component Groups:", prj.comp_groups])
writer.writerow(["Component Count:", prj.comp_total])
writer.writerow(["Fitted Components:", prj.comp_fitted])
writer.writerow(["Number of PCBs:", prj.number])
writer.writerow(["Total Components:", prj.comp_build])
def write_csv(filename, ext, groups, headings, head_names, cfg):
"""
Write BoM out to a CSV file
@ -50,19 +102,6 @@ def write_csv(filename, ext, groups, headings, head_names, cfg):
for i in range(5):
writer.writerow([])
# The info
if not cfg.csv.hide_pcb_info:
writer.writerow(["Project info:"])
writer.writerow(["Schematic:", cfg.source])
writer.writerow(["Variant:", cfg.variant.name])
writer.writerow(["Revision:", cfg.revision])
writer.writerow(["Date:", cfg.date])
writer.writerow(["KiCad Version:", cfg.kicad_version])
if not cfg.csv.hide_stats_info:
writer.writerow(["Statistics:"])
writer.writerow(["Component Groups:", cfg.n_groups])
writer.writerow(["Component Count:", cfg.n_total])
writer.writerow(["Fitted Components:", cfg.n_fitted])
writer.writerow(["Number of PCBs:", cfg.number])
writer.writerow(["Total Components:", cfg.n_build])
write_stats(writer, cfg)
return True

View File

@ -32,6 +32,7 @@ STYLE_COMMON = (" .cell-title { vertical-align: bottom; }\n"
" .cell-info { vertical-align: top; padding: 1em;}\n"
" .cell-stats { vertical-align: top; padding: 1em;}\n"
" .title { font-size:2.5em; font-weight: bold; }\n"
" .subtitle { font-size:1.5em; font-weight: bold; }\n"
" .h2 { font-size:1.5em; font-weight: bold; }\n"
" .td-empty0 { text-align: center; background-color: "+BG_EMPTY+";}\n"
" .td-gen0 { text-align: center; background-color: "+BG_GEN+";}\n"
@ -274,6 +275,75 @@ def embed_image(file):
return int(w), int(h), 'data:image/png;base64,'+b64encode(s).decode('ascii')
def write_stats(html, cfg):
if len(cfg.aggregate) == 1:
# Only one project
html.write('<tr>\n')
html.write(' <td class="cell-info">\n')
if not cfg.html.hide_pcb_info:
prj = cfg.aggregate[0]
html.write(" <b>Schematic</b>: {}<br>\n".format(prj.name))
html.write(" <b>Variant</b>: {}<br>\n".format(cfg.variant.name))
html.write(" <b>Revision</b>: {}<br>\n".format(prj.sch.revision))
html.write(" <b>Date</b>: {}<br>\n".format(prj.sch.date))
html.write(" <b>KiCad Version</b>: {}<br>\n".format(cfg.kicad_version))
html.write(' </td>\n')
html.write(' <td class="cell-stats">\n')
if not cfg.html.hide_stats_info:
html.write(" <b>Component Groups</b>: {}<br>\n".format(cfg.n_groups))
html.write(" <b>Component Count</b>: {} (per PCB)<br>\n\n".format(cfg.n_total))
html.write(" <b>Fitted Components</b>: {} (per PCB)<br>\n".format(cfg.n_fitted))
html.write(" <b>Number of PCBs</b>: {}<br>\n".format(cfg.number))
html.write(" <b>Total Components</b>: {t} (for {n} PCBs)<br>\n".format(n=cfg.number, t=cfg.n_build))
html.write(' </td>\n')
html.write('</tr>\n')
else:
# Multiple projects
# Global stats
html.write('<tr>\n')
html.write(' <td class="cell-info">\n')
if not cfg.html.hide_pcb_info:
html.write(" <b>Variant</b>: {}<br>\n".format(cfg.variant.name))
html.write(" <b>KiCad Version</b>: {}<br>\n".format(cfg.kicad_version))
html.write(' </td>\n')
html.write(' <td class="cell-stats">\n')
if not cfg.html.hide_stats_info:
html.write(" <b>Component Groups</b>: {}<br>\n".format(cfg.n_groups))
html.write(" <b>Component Count</b>: {} (per PCB)<br>\n\n".format(cfg.n_total))
html.write(" <b>Fitted Components</b>: {} (per PCB)<br>\n".format(cfg.n_fitted))
html.write(" <b>Number of PCBs</b>: {}<br>\n".format(cfg.number))
html.write(" <b>Total Components</b>: {t} (for {n} PCBs)<br>\n".format(n=cfg.number, t=cfg.n_build))
html.write(' </td>\n')
html.write('</tr>\n')
# Individual stats
for prj in cfg.aggregate:
html.write('<tr>\n')
html.write(' <td colspan="2" class="cell-title">\n')
html.write(' <div class="subtitle">'+prj.sch.title+'</div>\n')
html.write(' </td>\n')
html.write('</tr>\n')
html.write('<tr>\n')
html.write(' <td class="cell-info">\n')
if not cfg.html.hide_pcb_info:
html.write(" <b>Schematic</b>: {}<br>\n".format(prj.name))
html.write(" <b>Revision</b>: {}<br>\n".format(prj.sch.revision))
html.write(" <b>Date</b>: {}<br>\n".format(prj.sch.date))
if prj.sch.company:
html.write(" <b>Company</b>: {}<br>\n".format(prj.sch.company))
if prj.ref_id:
html.write(" <b>ID</b>: {}<br>\n".format(prj.ref_id))
html.write(' </td>\n')
html.write(' <td class="cell-stats">\n')
if not cfg.html.hide_stats_info:
html.write(" <b>Component Groups</b>: {}<br>\n".format(prj.comp_groups))
html.write(" <b>Component Count</b>: {} (per PCB)<br>\n\n".format(prj.comp_total))
html.write(" <b>Fitted Components</b>: {} (per PCB)<br>\n".format(prj.comp_fitted))
html.write(" <b>Number of PCBs</b>: {}<br>\n".format(prj.number))
html.write(" <b>Total Components</b>: {t} (for {n} PCBs)<br>\n".format(n=prj.number, t=prj.comp_build))
html.write(' </td>\n')
html.write('</tr>\n')
def write_html(filename, groups, headings, head_names, cfg):
"""
Write BoM out to a HTML file
@ -340,7 +410,10 @@ def write_html(filename, groups, headings, head_names, cfg):
if img or not cfg.html.hide_pcb_info or not cfg.html.hide_stats_info or cfg.html.title:
html.write('<table class="head-table">\n')
html.write('<tr>\n')
html.write(' <td rowspan="2">\n')
n = 2
if len(cfg.aggregate) > 1:
n += 2*len(cfg.aggregate)
html.write(' <td rowspan="{}">\n'.format(n))
if img:
html.write(' <img src="'+img+'" alt="Logo" width="'+str(img_w)+'" height="'+str(img_h)+'">\n')
html.write(' </td>\n')
@ -349,24 +422,7 @@ def write_html(filename, groups, headings, head_names, cfg):
html.write(' <div class="title">'+cfg.html.title+'</div>\n')
html.write(' </td>\n')
html.write('</tr>\n')
html.write('<tr>\n')
html.write(' <td class="cell-info">\n')
if not cfg.html.hide_pcb_info:
html.write(" <b>Schematic</b>: {}<br>\n".format(cfg.source))
html.write(" <b>Variant</b>: {}<br>\n".format(cfg.variant.name))
html.write(" <b>Revision</b>: {}<br>\n".format(cfg.revision))
html.write(" <b>Date</b>: {}<br>\n".format(cfg.date))
html.write(" <b>KiCad Version</b>: {}<br>\n".format(cfg.kicad_version))
html.write(' </td>\n')
html.write(' <td class="cell-stats">\n')
if not cfg.html.hide_stats_info:
html.write(" <b>Component Groups</b>: {}<br>\n".format(cfg.n_groups))
html.write(" <b>Component Count</b>: {} (per PCB)<br>\n\n".format(cfg.n_total))
html.write(" <b>Fitted Components</b>: {} (per PCB)<br>\n".format(cfg.n_fitted))
html.write(" <b>Number of PCBs</b>: {}<br>\n".format(cfg.number))
html.write(" <b>Total Components</b>: {t} (for {n} PCBs)<br>\n".format(n=cfg.number, t=cfg.n_build))
html.write(' </td>\n')
html.write('</tr>\n')
write_stats(html, cfg)
html.write('</table>\n')
# Fitted groups

View File

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020 Salvador E. Tropea
# Copyright (c) 2020 Instituto Nacional de Tecnología Industrial
# 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)
@ -67,14 +67,16 @@ def add_info(worksheet, column_widths, row, col_offset, formats, text, value):
def compute_head_size(cfg):
head_size = 7
if cfg.xlsx.logo is None:
if not cfg.xlsx.title:
head_size -= 1
if cfg.xlsx.hide_pcb_info and cfg.xlsx.hide_stats_info:
head_size -= 5
if head_size == 1:
head_size = 0
col_logo = 0 if cfg.xlsx.logo is None else 6
col_info = 1 if cfg.xlsx.title else 0
if not (cfg.xlsx.hide_pcb_info and cfg.xlsx.hide_stats_info):
col_info += 5
if len(cfg.aggregate) > 1:
col_info += 6*len(cfg.aggregate)
head_size = max(col_logo, col_info)
if head_size:
# To separate
head_size += 1
return head_size
@ -116,6 +118,15 @@ def create_fmt_title(workbook, title):
return fmt_title
def create_fmt_subtitle(workbook):
fmt_title = workbook.add_format(DEFAULT_FMT)
fmt_title.set_font_size(18)
fmt_title.set_bold()
fmt_title.set_font_name('Arial')
fmt_title.set_align('left')
return fmt_title
def create_fmt_cols(workbook, col_colors):
""" Create the possible column formats """
fmt_cols = []
@ -202,6 +213,67 @@ def adjust_heights(worksheet, rows, max_width, head_size):
worksheet.set_row(head_size+rn, 15.0*max_h)
def write_info(cfg, r_info_start, worksheet, column_widths, col1, fmt_info, fmt_subtitle):
if len(cfg.aggregate) == 1:
# Only one project
rc = r_info_start
if not cfg.xlsx.hide_pcb_info:
prj = cfg.aggregate[0]
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Schematic:", prj.name)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Variant:", cfg.variant.name)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Revision:", prj.sch.revision)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Date:", prj.sch.date)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "KiCad Version:", cfg.kicad_version)
col1 += 2
rc = r_info_start
if not cfg.xlsx.hide_stats_info:
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Component Groups:", cfg.n_groups)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Component Count:", cfg.n_total)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Fitted Components:", cfg.n_fitted)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Number of PCBs:", cfg.number)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Total Components:", cfg.n_build)
else:
# Multiple projects
# Global stats
old_col1 = col1
rc = r_info_start
if not cfg.xlsx.hide_pcb_info:
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Variant:", cfg.variant.name)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "KiCad Version:", cfg.kicad_version)
col1 += 2
rc = r_info_start
if not cfg.xlsx.hide_stats_info:
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Component Groups:", cfg.n_groups)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Component Count:", cfg.n_total)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Fitted Components:", cfg.n_fitted)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Number of PCBs:", cfg.number)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Total Components:", cfg.n_build)
# Individual stats
for prj in cfg.aggregate:
r_info_start += 5
col1 = old_col1
worksheet.set_row(r_info_start, 24)
worksheet.merge_range(r_info_start, col1, r_info_start, len(column_widths)-1, prj.sch.title, fmt_subtitle)
r_info_start += 1
rc = r_info_start
if not cfg.xlsx.hide_pcb_info:
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Schematic:", prj.name)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Revision:", prj.sch.revision)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Date:", prj.sch.date)
if prj.sch.company:
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Company:", prj.sch.company)
if prj.ref_id:
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "ID:", prj.ref_id)
col1 += 2
rc = r_info_start
if not cfg.xlsx.hide_stats_info:
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Component Groups:", prj.comp_groups)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Component Count:", prj.comp_total)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Fitted Components:", prj.comp_fitted)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Number of PCBs:", prj.number)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Total Components:", prj.comp_build)
def write_xlsx(filename, groups, col_fields, head_names, cfg):
"""
Write BoM out to a XLSX file
@ -245,6 +317,7 @@ def write_xlsx(filename, groups, col_fields, head_names, cfg):
image_data = get_logo_data(cfg.xlsx.logo)
# Title
fmt_title = create_fmt_title(workbook, cfg.xlsx.title)
fmt_subtitle = create_fmt_subtitle(workbook)
# Info
fmt_info = create_fmt_info(workbook, cfg)
@ -310,21 +383,7 @@ def write_xlsx(filename, groups, col_fields, head_names, cfg):
worksheet.merge_range(0, col1, 0, len(column_widths)-1, cfg.xlsx.title, fmt_title)
# PCB & Stats Info
if not (cfg.xlsx.hide_pcb_info and cfg.xlsx.hide_stats_info):
rc = r_info_start
if not cfg.xlsx.hide_pcb_info:
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Schematic:", cfg.source)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Variant:", cfg.variant.name)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Revision:", cfg.revision)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Date:", cfg.date)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "KiCad Version:", cfg.kicad_version)
col1 += 2
rc = r_info_start
if not cfg.xlsx.hide_stats_info:
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Component Groups:", cfg.n_groups)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Component Count:", cfg.n_total)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Fitted Components:", cfg.n_fitted)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Number of PCBs:", cfg.number)
rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Total Components:", cfg.n_build)
write_info(cfg, r_info_start, worksheet, column_widths, col1, fmt_info, fmt_subtitle)
# Adjust cols and rows
adjust_widths(worksheet, column_widths, max_width)

View File

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020 Salvador E. Tropea
# Copyright (c) 2020 Instituto Nacional de Tecnología Industrial
# 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)
@ -23,9 +23,6 @@ def write_xml(filename, groups, headings, head_names, cfg):
cfg = BoMOptions object with all the configuration
"""
attrib = {}
attrib['Schematic_Source'] = cfg.source
attrib['Schematic_Revision'] = cfg.revision
attrib['Schematic_Date'] = cfg.date
attrib['PCB_Variant'] = cfg.variant.name
attrib['KiCad_Version'] = cfg.kicad_version
attrib['Component_Groups'] = str(cfg.n_groups)
@ -33,7 +30,22 @@ def write_xml(filename, groups, headings, head_names, cfg):
attrib['Fitted_Components'] = str(cfg.n_fitted)
attrib['Number_of_PCBs'] = str(cfg.number)
attrib['Total_Components'] = str(cfg.n_build)
if len(cfg.aggregate) == 1:
prj = cfg.aggregate[0]
attrib['Schematic_Source'] = prj.name
attrib['Schematic_Revision'] = prj.sch.revision
attrib['Schematic_Date'] = prj.sch.date
else:
for n, prj in enumerate(cfg.aggregate):
attrib['Schematic{}_Source'.format(n)] = prj.name
attrib['Schematic{}_Revision'.format(n)] = prj.sch.revision
attrib['Schematic{}_Date'.format(n)] = prj.sch.date
attrib['Schematic{}_ID'.format(n)] = prj.ref_id
attrib['Component_Groups{}'.format(n)] = str(prj.comp_groups)
attrib['Component_Count{}'.format(n)] = str(prj.comp_total)
attrib['Fitted_Components{}'.format(n)] = str(prj.comp_fitted)
attrib['Number_of_PCBs{}'.format(n)] = str(prj.number)
attrib['Total_Components{}'.format(n)] = str(prj.comp_build)
xml = ElementTree.Element('KiCad_BOM', attrib=attrib, encoding='utf-8')
for group in groups:
if cfg.ignore_dnf and not group.is_fitted():

View File

@ -4,7 +4,6 @@
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
import os
import re
from datetime import datetime
from sys import exit
from .misc import (EXIT_BAD_ARGS)
@ -94,37 +93,11 @@ class GS(object):
def load_sch_title_block():
if GS.sch_title is not None:
return
GS.sch_title = ''
GS.sch_date = ''
GS.sch_rev = ''
GS.sch_comp = ''
re_val = re.compile(r"(\w+)\s+\"([^\"]+)\"")
with open(GS.sch_file) as f:
for line in f:
m = re_val.match(line)
if not m:
if line.startswith("$EndDescr"):
break
# This line is executed, but coverage fails to detect it
continue # pragma: no cover
name, val = m.groups()
if name == "Title":
GS.sch_title = val
elif name == "Date":
GS.sch_date = val
elif name == "Rev":
GS.sch_rev = val
elif name == "Comp":
GS.sch_comp = val
if not GS.sch_date:
file_mtime = os.path.getmtime(GS.sch_file)
GS.sch_date = datetime.fromtimestamp(file_mtime).strftime('%Y-%m-%d_%H-%M-%S')
if not GS.sch_title:
GS.sch_title = GS.sch_basename
logger.debug("SCH title: `{}`".format(GS.sch_title))
logger.debug("SCH date: `{}`".format(GS.sch_date))
logger.debug("SCH revision: `{}`".format(GS.sch_rev))
logger.debug("SCH company: `{}`".format(GS.sch_comp))
assert GS.sch is not None
GS.sch_title = GS.sch.title
GS.sch_date = GS.sch.date
GS.sch_rev = GS.sch.revision
GS.sch_comp = GS.sch.company
@staticmethod
def load_pcb_title_block():

View File

@ -11,6 +11,7 @@ Currently oriented to collect the components for the BoM.
# Encapsulate file/line
import re
import os
from datetime import datetime
from copy import deepcopy
from collections import OrderedDict
from .config import KiConf, un_quote
@ -807,6 +808,10 @@ class SchematicComponent(object):
self.footprint = ''
self.datasheet = ''
self.desc = ''
self.fields = []
self.dfields = {}
self.fields_bkp = None
self.dfields_bkp = None
# Will be computed
self.fitted = True
self.included = True
@ -941,7 +946,7 @@ class SchematicComponent(object):
return '{} ({} {})'.format(ref, self.name, self.value)
@staticmethod
def load(f, sheet_path, sheet_path_h, libs, fields, fields_lc):
def load(f, project, sheet_path, sheet_path_h, libs, fields, fields_lc):
# L lib:name reference
line = f.get_line()
if not line or line[0] != 'L':
@ -950,6 +955,7 @@ class SchematicComponent(object):
if len(res) != 2:
raise SchFileError('Malformed component label', line, f)
comp = SchematicComponent()
comp.project = project
comp.name, comp.f_ref = res
res = comp.name.split(':')
comp.lib = None
@ -985,10 +991,6 @@ class SchematicComponent(object):
comp.ar.append(SchematicAltRef.parse(line))
line = f.get_line()
# F field_number "text" orientation posX posY size Flags (see below) hjustify vjustify/italic/bold "name"
comp.fields = []
comp.dfields = {}
comp.fields_bkp = None
comp.dfields_bkp = None
while line[0] == 'F':
field = SchematicField.parse(line, f)
name_lc = field.name.lower()
@ -1275,7 +1277,7 @@ class SchematicSheet(object):
self.sheet = None
self.id = ''
def load_sheet(self, parent, sheet_path, sheet_path_h, libs, fields, fields_lc):
def load_sheet(self, project, parent, sheet_path, sheet_path_h, libs, fields, fields_lc):
assert self.name
self.sheet = Schematic()
parent_dir = os.path.dirname(parent)
@ -1283,7 +1285,7 @@ class SchematicSheet(object):
if len(sheet_path_h) > 1:
sheet_path_h += '/'
sheet_path_h += self.name if self.name else 'Unknown'
self.sheet.load(os.path.join(parent_dir, self.file), sheet_path, sheet_path_h, libs, fields, fields_lc)
self.sheet.load(os.path.join(parent_dir, self.file), project, sheet_path, sheet_path_h, libs, fields, fields_lc)
return self.sheet
@staticmethod
@ -1366,6 +1368,10 @@ class Schematic(object):
while True:
line = f.get_line()
if line.startswith('$EndDescr'):
self.title = self.title_block['Title'] if 'Title' in self.title_block else ''
self.date = self.title_block['Date'] if 'Date' in self.title_block else ''
self.revision = self.title_block['Rev'] if 'Rev' in self.title_block else ''
self.company = self.title_block['Comp'] if 'Comp' in self.title_block else ''
return
elif line.startswith('encoding'):
if line[9:14] != 'utf-8':
@ -1382,7 +1388,7 @@ class Schematic(object):
raise SchFileError('Wrong entry in title block', line, f)
self.title_block[m.group(1)] = m.group(2)
def load(self, fname, sheet_path='', sheet_path_h='/', libs={}, fields=[], fields_lc=set()):
def load(self, fname, project, sheet_path='', sheet_path_h='/', libs={}, fields=[], fields_lc=set()):
""" Load a v5.x KiCad Schematic.
The caller must be sure the file exists.
Only the schematics are loaded not the libs. """
@ -1391,6 +1397,7 @@ class Schematic(object):
self.libs = libs
self.fields = fields
self.fields_lc = fields_lc
self.project = project
with open(fname, 'rt') as fh:
f = SCHLineReader(fh, fname)
line = f.get_line()
@ -1410,7 +1417,18 @@ class Schematic(object):
line = f.get_line()
if not line.startswith('EELAYER END'):
raise SchFileError('Missing EELAYER END', line, f)
# Load the title block
self._get_title_block(f)
# Fill in some missing info
if not self.date:
file_mtime = os.path.getmtime(fname)
self.date = datetime.fromtimestamp(file_mtime).strftime('%Y-%m-%d_%H-%M-%S')
if not self.title:
self.title = os.path.splitext(os.path.basename(fname))[0]
logger.debug("SCH title: `{}`".format(self.title))
logger.debug("SCH date: `{}`".format(self.date))
logger.debug("SCH revision: `{}`".format(self.revision))
logger.debug("SCH company: `{}`".format(self.company))
line = f.get_line()
self.all = []
self.components = []
@ -1421,7 +1439,7 @@ class Schematic(object):
self.sheets = []
while not line.startswith('$EndSCHEMATC'):
if line.startswith('$Comp'):
obj = SchematicComponent.load(f, sheet_path, sheet_path_h, libs, fields, fields_lc)
obj = SchematicComponent.load(f, project, sheet_path, sheet_path_h, libs, fields, fields_lc)
self.components.append(obj)
elif line.startswith('NoConn'):
obj = SchematicConnection.parse(False, line[7:], f)
@ -1448,7 +1466,7 @@ class Schematic(object):
# Load sub-sheets
self.sub_sheets = []
for sch in self.sheets:
self.sub_sheets.append(sch.load_sheet(fname, sheet_path, sheet_path_h, libs, fields, fields_lc))
self.sub_sheets.append(sch.load_sheet(project, fname, sheet_path, sheet_path_h, libs, fields, fields_lc))
def get_files(self):
""" A list of the names for all the sheets, including this one. """

View File

@ -145,19 +145,12 @@ def load_board(pcb_file=None):
return board
def load_sch():
if GS.sch: # Already loaded
return
GS.check_sch()
# We can't yet load the new format
if GS.sch_file[-9:] == 'kicad_sch':
return
GS.sch = Schematic()
def load_any_sch(sch, file, project):
try:
GS.sch.load(GS.sch_file)
GS.sch.load_libs(GS.sch_file)
sch.load(file, project)
sch.load_libs(file)
if GS.debug_level > 1:
logger.debug('Schematic dependencies: '+str(GS.sch.get_files()))
logger.debug('Schematic dependencies: '+str(sch.get_files()))
except SchFileError as e:
trace_dump()
logger.error('At line {} of `{}`: {}'.format(e.line, e.file, e.msg))
@ -170,6 +163,17 @@ def load_sch():
exit(EXIT_BAD_CONFIG)
def load_sch():
if GS.sch: # Already loaded
return
GS.check_sch()
# We can't yet load the new format
if GS.sch_file[-9:] == 'kicad_sch':
return
GS.sch = Schematic()
load_any_sch(GS.sch, GS.sch_file, GS.sch_basename)
def get_board_comps_data(comps):
""" Add information from the PCB to the list of components from the schematic.
Note that we do it every time the function is called to reset transformation filters like rot_footprint. """

View File

@ -12,12 +12,13 @@ from .gs import GS
from .optionable import Optionable, BaseOptions
from .registrable import RegOutput
from .error import KiPlotConfigurationError
from .kiplot import get_board_comps_data
from .macros import macros, document, output_class # noqa: F401
from .kiplot import get_board_comps_data, load_any_sch
from .bom.columnlist import ColumnList, BoMError
from .bom.bom import do_bom
from .var_kibom import KiBoM
from .kicad.v5_sch import Schematic
from .fil_base import BaseFilter, apply_exclude_filter, apply_fitted_filter, apply_fixed_filter, reset_filters
from .macros import macros, document, output_class # noqa: F401
from . import log
# To debug the `with document` we can use:
# from .mcpyrate.debug import macros, step_expansion
@ -186,6 +187,27 @@ class NoConflict(Optionable):
super().__init__()
class Aggregate(Optionable):
def __init__(self):
super().__init__()
with document:
self.file = ''
""" Name of the schematic to aggregate """
self.name = ''
""" Name to identify this source. If empty we use the name of the schematic """
self.ref_id = ''
""" A prefix to add to all the references from this project """
self.number = 1
""" Number of boards to build (components multiplier). Use negative to substract """
def config(self):
super().config()
if not self.file:
raise KiPlotConfigurationError("Missing or empty `file` in aggregate list ({})".format(str(self._tree)))
if not self.name:
self.name = os.path.splitext(os.path.basename(self.file))[0]
class BoMOptions(BaseOptions):
def __init__(self):
with document:
@ -256,6 +278,12 @@ class BoMOptions(BaseOptions):
""" [list(string)] List of fields where we tolerate conflicts.
Use it to avoid undesired warnings.
By default the field indicated in `fit_field` and the field `part` are excluded """
self.aggregate = Aggregate
""" [list(dict)] Add components from other projects """
self.ref_id = ''
""" A prefix to add to all the references from this project. Used for multiple projects """
self.source_by_id = False
""" Generate the `Source BoM` column using the reference ID instead of the project name """
self._format_example = 'CSV'
super().__init__()
@ -341,6 +369,9 @@ class BoMOptions(BaseOptions):
for field in self.no_conflict:
no_conflict.add(field.lower())
self.no_conflict = no_conflict
# Make sure aggregate is a list
if isinstance(self.aggregate, type):
self.aggregate = []
# Columns
self.column_rename = {}
self.join = []
@ -351,9 +382,11 @@ class BoMOptions(BaseOptions):
# Ignore the part and footprint library, also sheetpath and the Reference in singular
ignore = [ColumnList.COL_PART_LIB_L, ColumnList.COL_FP_LIB_L, ColumnList.COL_SHEETPATH_L,
ColumnList.COL_REFERENCE_L[:-1]]
if self.number <= 1:
# For one board avoid COL_GRP_BUILD_QUANTITY
ignore.append(ColumnList.COL_GRP_BUILD_QUANTITY_L)
if len(self.aggregate) == 0:
ignore.append(ColumnList.COL_SOURCE_BOM_L)
if self.number == 1:
# For one board avoid COL_GRP_BUILD_QUANTITY
ignore.append(ColumnList.COL_GRP_BUILD_QUANTITY_L)
# Exclude the particular columns
self.columns = [h for h in valid_columns if not h.lower() in ignore]
else:
@ -384,6 +417,24 @@ class BoMOptions(BaseOptions):
# This is the ordered list with the case style defined by the user
self.columns = columns
def aggregate_comps(self, comps):
self.qtys = {GS.sch_basename: self.number}
for prj in self.aggregate:
if not os.path.isfile(prj.file):
raise KiPlotConfigurationError("Missing `{}`".format(prj.file))
logger.debug('Adding components from project {} ({}) using reference id `{}`'.
format(prj.name, prj.file, prj.ref_id))
self.qtys[prj.name] = prj.number
prj.sch = Schematic()
load_any_sch(prj.sch, prj.file, prj.name)
new_comps = prj.sch.get_components()
if prj.ref_id:
for c in new_comps:
c.ref = prj.ref_id+c.ref
c.ref_id = prj.ref_id
comps.extend(new_comps)
prj.source = os.path.basename(prj.file)
def run(self, output_dir):
format = self.format.lower()
output = self.expand_filename_sch(output_dir, self.output, 'bom', format)
@ -397,6 +448,12 @@ class BoMOptions(BaseOptions):
# Get the components list from the schematic
comps = GS.sch.get_components()
get_board_comps_data(comps)
# Apply the reference prefix
for c in comps:
c.ref = self.ref_id+c.ref
c.ref_id = self.ref_id
# Aggregate components from other projects
self.aggregate_comps(comps)
# Apply all the filters
reset_filters(comps)
apply_exclude_filter(comps, self.exclude_filter)
@ -404,10 +461,27 @@ class BoMOptions(BaseOptions):
apply_fixed_filter(comps, self.dnc_filter)
# Apply the variant
self.variant.filter(comps)
# We add the main project to the aggregate list so do_bom sees a complete list
base_sch = Aggregate()
base_sch.file = GS.sch_file
base_sch.name = GS.sch_basename
base_sch.ref_id = self.ref_id
base_sch.number = self.number
base_sch.sch = GS.sch
self.aggregate.insert(0, base_sch)
# To translate project to ID
if self.source_by_id:
self.source_to_id = {prj.name: prj.ref_id for prj in self.aggregate}
try:
do_bom(output, format, comps, self)
except BoMError as e:
raise KiPlotConfigurationError(str(e))
# Undo the reference prefix
if self.ref_id:
l_id = len(self.ref_id)
for c in filter(lambda c: c.project == GS.sch_basename, comps):
c.ref = c.ref[l_id:]
c.ref_id = ''
def get_targets(self, parent, out_dir):
return [self.expand_filename_sch(out_dir, self.output, 'bom', self.format.lower())]

View File

@ -7,7 +7,7 @@ from sys import (exit)
from .macros import macros, pre_class # noqa: F401
from .gs import (GS)
from .optionable import Optionable
from .kiplot import check_eeschema_do, exec_with_retry
from .kiplot import check_eeschema_do, exec_with_retry, load_sch
from .error import (KiPlotConfigurationError)
from .misc import (CMD_EESCHEMA_DO, ERC_ERROR)
from .log import (get_logger)
@ -28,6 +28,9 @@ class Run_ERC(BasePreFlight): # noqa: F821
def run(self):
check_eeschema_do()
# The schematic is loaded only before executing an output related to it.
# But here we need data from it.
load_sch()
output = Optionable.expand_filename_sch(None, GS.out_dir, GS.def_global_output, 'erc', 'txt')
logger.debug('ERC report: '+output)
cmd = [CMD_EESCHEMA_DO, 'run_erc', '-o', output]

View File

@ -87,7 +87,7 @@ U 1 1 5E6A448B
P 2200 3650
F 0 "R9" V 2280 3650 50 0000 C CNN
F 1 "100R" V 2200 3650 50 0000 C CNN
F 2 "Resistor_SMD:R_0603_1608Metric" V 2130 3650 50 0001 C CNN
F 2 "Resistor_SMD:R_0805_2012Metric" V 2130 3650 50 0001 C CNN
F 3 "~" H 2200 3650 50 0001 C CNN
1 2200 3650
1 0 0 -1
@ -98,7 +98,7 @@ U 1 1 5E6A491A
P 2500 3650
F 0 "R10" V 2580 3650 50 0000 C CNN
F 1 "100" V 2500 3650 50 0000 C CNN
F 2 "Resistor_SMD:R_0603_1608Metric" V 2430 3650 50 0001 C CNN
F 2 "Resistor_SMD:R_0805_2012Metric" V 2430 3650 50 0001 C CNN
F 3 "~" H 2500 3650 50 0001 C CNN
1 2500 3650
1 0 0 -1

View File

@ -23,7 +23,7 @@ if prev_dir not in sys.path:
sys.path.insert(0, prev_dir)
# Utils import
from utils import context
from kibot.misc import (DRC_ERROR, ERC_ERROR, BOM_ERROR, CORRUPTED_PCB)
from kibot.misc import (DRC_ERROR, ERC_ERROR, BOM_ERROR, CORRUPTED_PCB, CORRUPTED_SCH)
def test_erc_1():
@ -49,7 +49,7 @@ def test_erc_fail_2():
""" Using a dummy SCH """
prj = '3Rs'
ctx = context.TestContext('ERCFail2', prj, 'erc', '')
ctx.run(ERC_ERROR)
ctx.run(CORRUPTED_SCH)
ctx.clean_up()

View File

@ -67,7 +67,7 @@ def check_l1(ctx):
ctx.expect_out_file(o_name)
sch = Schematic()
try:
sch.load(ctx.get_out_path(o_name))
sch.load(ctx.get_out_path(o_name), 'no_project')
except SchFileError as e:
logging.error('At line {} of `{}`: {}'.format(e.line, e.file, e.msg))
logging.error('Line content: `{}`'.format(e.code))