From e85ee5d28588820292d566d9b40f090698121870 Mon Sep 17 00:00:00 2001 From: Tyler Ward Date: Thu, 22 Oct 2020 16:53:33 +0100 Subject: [PATCH] Allow addittional BOM items within components (#115) --- docs/syntax.md | 57 ++++++---- src/wireviz/DataClasses.py | 54 ++++++++++ src/wireviz/Harness.py | 211 ++++++++++++++++++++++--------------- src/wireviz/wv_helper.py | 29 ++++- tutorial/tutorial08.md | 5 +- tutorial/tutorial08.yml | 20 +++- 6 files changed, 266 insertions(+), 110 deletions(-) diff --git a/docs/syntax.md b/docs/syntax.md index a0a30c27..a0d9041b 100644 --- a/docs/syntax.md +++ b/docs/syntax.md @@ -43,9 +43,12 @@ additional_bom_items: # custom items to add to BOM notes: # product information (all optional) - pn: # [internal] part number - mpn: # manufacturer part number - manufacturer: # manufacturer name + ignore_in_bom: # if set to true the connector is not added to the BOM + pn: # [internal] part number + mpn: # manufacturer part number + manufacturer: # manufacturer name + additional_components: # additional components + - # additional component (see below) # pinout information # at least one of the following must be specified @@ -108,9 +111,12 @@ Since the auto-incremented and auto-assigned designator is not known to the user notes: # product information (all optional) - pn: # [internal] part number - mpn: # manufacturer part number - manufacturer: # manufacturer name + ignore_in_bom: # if set to true the cable or wires are not added to the BOM + pn: # [internal] part number + mpn: # manufacturer part number + manufacturer: # manufacturer name + additional_components: # additional components + - # additional component (see below) # conductor information # the following combinations are permitted: @@ -212,27 +218,42 @@ For connectors with `autogenerate: true`, a new instance, with auto-generated de - `-` auto-expands to a range. -## BOM items +## BOM items and additional components -Connectors (both regular, and auto-generated), cables, and wires of a bundle are automatically added to the BOM. +Connectors (both regular, and auto-generated), cables, and wires of a bundle are automatically added to the BOM, +unless the `ignore_in_bom` attribute is set to `true`. +Additional items can be added to the BOM as either part of a connector or cable or on their own. - +Parts can be added to a connector or cable in the section `` which will also list them in the graph. -Additional BOM entries can be generated in the sections marked `` above. +```yaml +- + type: # type of additional component + # all the following are optional: + subtype: # additional description (only shown in bom) + qty: # qty to add to the bom (defaults to 1) + qty_multiplier: # multiplies qty by a feature of the parent component + # when used in a connector: + # pincount number of pins of connector + # populated number of populated positions in a connector + # when used in a cable: + # wirecount number of wires of cable/bundle + # terminations number of terminations on a cable/bundle + # length length of cable/bundle + # total_length sum of lengths of each wire in the bundle + unit: + pn: # [internal] part number + mpn: # manufacturer part number + manufacturer: # manufacturer name +``` - +Alternatively items can be added to just the BOM by putting them in the section `` above. ```yaml - description: - qty: # when used in the additional_bom_items section: - # manually specify qty. - # when used within a component: - # manually specify qty. - # pincount match number of pins of connector - # wirecount match number of wires of cable/bundle - # connectioncount match number of connected pins # all the following are optional: + qty: # qty to add to the bom (defaults to 1) unit: designators: pn: # [internal] part number diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index 3ad84d21..e813fbe3 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -7,6 +7,10 @@ from wireviz.wv_helper import int2tuple, aspect_ratio from wireviz import wv_colors +# Literal type aliases below are commented to avoid requiring python 3.8 +ConnectorMultiplier = str # = Literal['pincount', 'populated'] +CableMultiplier = str # = Literal['wirecount', 'terminations', 'length', 'total_length'] + @dataclass class Image: @@ -43,6 +47,21 @@ def __post_init__(self, gv_dir): if self.width: self.height = self.width / aspect_ratio(gv_dir.joinpath(self.src)) +@dataclass +class AdditionalComponent: + type: str + subtype: Optional[str] = None + manufacturer: Optional[str] = None + mpn: Optional[str] = None + pn: Optional[str] = None + qty: float = 1 + unit: Optional[str] = None + qty_multiplier: Union[ConnectorMultiplier, CableMultiplier, None] = None + + @property + def description(self) -> str: + return self.type.rstrip() + (f', {self.subtype.rstrip()}' if self.subtype else '') + @dataclass class Connector: @@ -65,6 +84,8 @@ class Connector: hide_disconnected_pins: bool = False autogenerate: bool = False loops: List[Any] = field(default_factory=list) + ignore_in_bom: bool = False + additional_components: List[AdditionalComponent] = field(default_factory=list) def __post_init__(self): @@ -114,9 +135,23 @@ def __post_init__(self): if len(loop) != 2: raise Exception('Loops must be between exactly two pins!') + for i, item in enumerate(self.additional_components): + if isinstance(item, dict): + self.additional_components[i] = AdditionalComponent(**item) + def activate_pin(self, pin): self.visible_pins[pin] = True + def get_qty_multiplier(self, qty_multiplier: Optional[ConnectorMultiplier]) -> int: + if not qty_multiplier: + return 1 + elif qty_multiplier == 'pincount': + return self.pincount + elif qty_multiplier == 'populated': + return sum(self.visible_pins.values()) + else: + raise ValueError(f'invalid qty multiplier parameter for connector {qty_multiplier}') + @dataclass class Cable: @@ -139,6 +174,8 @@ class Cable: color_code: Optional[str] = None show_name: bool = True show_wirecount: bool = True + ignore_in_bom: bool = False + additional_components: List[AdditionalComponent] = field(default_factory=list) def __post_init__(self): @@ -196,6 +233,9 @@ def __post_init__(self): else: raise Exception('lists of part data are only supported for bundles') + for i, item in enumerate(self.additional_components): + if isinstance(item, dict): + self.additional_components[i] = AdditionalComponent(**item) def connect(self, from_name, from_pin, via_pin, to_name, to_pin): from_pin = int2tuple(from_pin) @@ -207,6 +247,20 @@ def connect(self, from_name, from_pin, via_pin, to_name, to_pin): # self.connections.append((from_name, from_pin[i], via_pin[i], to_name, to_pin[i])) self.connections.append(Connection(from_name, from_pin[i], via_pin[i], to_name, to_pin[i])) + def get_qty_multiplier(self, qty_multiplier: Optional[CableMultiplier]) -> float: + if not qty_multiplier: + return 1 + elif qty_multiplier == 'wirecount': + return self.wirecount + elif qty_multiplier == 'terminations': + return len(self.connections) + elif qty_multiplier == 'length': + return self.length + elif qty_multiplier == 'total_length': + return self.length * self.wirecount + else: + raise ValueError(f'invalid qty multiplier parameter for cable {qty_multiplier}') + @dataclass class Connection: diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index 8b401c21..437ac205 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -7,10 +7,10 @@ from wireviz.wv_colors import get_color_hex from wireviz.wv_helper import awg_equiv, mm2_equiv, tuplelist2tsv, \ nested_html_table, flatten2d, index_if_list, html_line_breaks, \ - graphviz_line_breaks, remove_line_breaks, open_file_read, open_file_write, \ - html_colorbar, html_image, html_caption, manufacturer_info_field + clean_whitespace, open_file_read, open_file_write, html_colorbar, \ + html_image, html_caption, manufacturer_info_field, component_table_entry from collections import Counter -from typing import List +from typing import List, Union from pathlib import Path import re @@ -19,8 +19,10 @@ class Harness: def __init__(self): self.color_mode = 'SHORT' + self.mini_bom_mode = True self.connectors = {} self.cables = {} + self._bom = [] # Internal Cache for generated bom self.additional_bom_items = [] def add_connector(self, name: str, *args, **kwargs) -> None: @@ -99,8 +101,9 @@ def create_graph(self) -> Graph: connector.color, html_colorbar(connector.color)], '' if connector.style != 'simple' else None, [html_image(connector.image)], - [html_caption(connector.image)], - [html_line_breaks(connector.notes)]] + [html_caption(connector.image)]] + rows.extend(self.get_additional_component_table(connector)) + rows.append([html_line_breaks(connector.notes)]) html.extend(nested_html_table(rows)) if connector.style != 'simple': @@ -172,8 +175,10 @@ def create_graph(self) -> Graph: cable.color, html_colorbar(cable.color)], '', [html_image(cable.image)], - [html_caption(cable.image)], - [html_line_breaks(cable.notes)]] + [html_caption(cable.image)]] + + rows.extend(self.get_additional_component_table(cable)) + rows.append([html_line_breaks(cable.notes)]) html.extend(nested_html_table(rows)) wirehtml = [] @@ -196,7 +201,7 @@ def create_graph(self) -> Graph: wirehtml.append(' ') wirehtml.append(' ') wirehtml.append(' ') - if(cable.category == 'bundle'): # for bundles individual wires can have part information + if cable.category == 'bundle': # for bundles individual wires can have part information # create a list of wire parameters wireidentification = [] if isinstance(cable.pn, list): @@ -207,7 +212,7 @@ def create_graph(self) -> Graph: if manufacturer_info: wireidentification.append(html_line_breaks(manufacturer_info)) # print parameters into a table row under the wire - if(len(wireidentification) > 0): + if len(wireidentification) > 0 : wirehtml.append(' ') wirehtml.append(' ') for attrib in wireidentification: @@ -329,91 +334,131 @@ def output(self, filename: (str, Path), view: bool = False, cleanup: bool = True file.write('') + def get_additional_component_table(self, component: Union[Connector, Cable]) -> List[str]: + rows = [] + if component.additional_components: + rows.append(["Additional components"]) + for extra in component.additional_components: + qty = extra.qty * component.get_qty_multiplier(extra.qty_multiplier) + if self.mini_bom_mode: + id = self.get_bom_index(extra.description, extra.unit, extra.manufacturer, extra.mpn, extra.pn) + rows.append(component_table_entry(f'#{id} ({extra.type.rstrip()})', qty, extra.unit)) + else: + rows.append(component_table_entry(extra.description, qty, extra.unit, extra.pn, extra.manufacturer, extra.mpn)) + return(rows) + + def get_additional_component_bom(self, component: Union[Connector, Cable]) -> List[dict]: + bom_entries = [] + for part in component.additional_components: + qty = part.qty * component.get_qty_multiplier(part.qty_multiplier) + bom_entries.append({ + 'item': part.description, + 'qty': qty, + 'unit': part.unit, + 'manufacturer': part.manufacturer, + 'mpn': part.mpn, + 'pn': part.pn, + 'designators': component.name if component.show_name else None + }) + return(bom_entries) + def bom(self): - bom = [] - bom_connectors = [] - bom_cables = [] - bom_extra = [] + # if the bom has previously been generated then return the generated bom + if self._bom: + return self._bom + bom_entries = [] + # connectors - connector_group = lambda c: (c.type, c.subtype, c.pincount, c.manufacturer, c.mpn, c.pn) - for group in Counter([connector_group(v) for v in self.connectors.values()]): - items = {k: v for k, v in self.connectors.items() if connector_group(v) == group} - shared = next(iter(items.values())) - designators = list(items.keys()) - designators.sort() - conn_type = f', {remove_line_breaks(shared.type)}' if shared.type else '' - conn_subtype = f', {remove_line_breaks(shared.subtype)}' if shared.subtype else '' - conn_pincount = f', {shared.pincount} pins' if shared.style != 'simple' else '' - conn_color = f', {shared.color}' if shared.color else '' - name = f'Connector{conn_type}{conn_subtype}{conn_pincount}{conn_color}' - item = {'item': name, 'qty': len(designators), 'unit': '', 'designators': designators if shared.show_name else '', - 'manufacturer': remove_line_breaks(shared.manufacturer), 'mpn': remove_line_breaks(shared.mpn), 'pn': shared.pn} - bom_connectors.append(item) - bom_connectors = sorted(bom_connectors, key=lambda k: k['item']) # https://stackoverflow.com/a/73050 - bom.extend(bom_connectors) + for connector in self.connectors.values(): + if not connector.ignore_in_bom: + description = ('Connector' + + (f', {connector.type}' if connector.type else '') + + (f', {connector.subtype}' if connector.subtype else '') + + (f', {connector.pincount} pins' if connector.show_pincount else '') + + (f', {connector.color}' if connector.color else '')) + bom_entries.append({ + 'item': description, 'qty': 1, 'unit': None, 'designators': connector.name if connector.show_name else None, + 'manufacturer': connector.manufacturer, 'mpn': connector.mpn, 'pn': connector.pn + }) + + # add connectors aditional components to bom + bom_entries.extend(self.get_additional_component_bom(connector)) + # cables # TODO: If category can have other non-empty values than 'bundle', maybe it should be part of item name? - # The category needs to be included in cable_group to keep the bundles excluded. - cable_group = lambda c: (c.category, c.type, c.gauge, c.gauge_unit, c.wirecount, c.shield, c.manufacturer, c.mpn, c.pn) - for group in Counter([cable_group(v) for v in self.cables.values() if v.category != 'bundle']): - items = {k: v for k, v in self.cables.items() if cable_group(v) == group} - shared = next(iter(items.values())) - designators = list(items.keys()) - designators.sort() - total_length = sum(i.length for i in items.values()) - cable_type = f', {remove_line_breaks(shared.type)}' if shared.type else '' - gauge_name = f' x {shared.gauge} {shared.gauge_unit}' if shared.gauge else ' wires' - shield_name = ' shielded' if shared.shield else '' - name = f'Cable{cable_type}, {shared.wirecount}{gauge_name}{shield_name}' - item = {'item': name, 'qty': round(total_length, 3), 'unit': 'm', 'designators': designators, - 'manufacturer': remove_line_breaks(shared.manufacturer), 'mpn': remove_line_breaks(shared.mpn), 'pn': shared.pn} - bom_cables.append(item) - # bundles (ignores wirecount) - wirelist = [] - # list all cables again, since bundles are represented as wires internally, with the category='bundle' set - for bundle in self.cables.values(): - if bundle.category == 'bundle': - # add each wire from each bundle to the wirelist - for index, color in enumerate(bundle.colors, 0): - wirelist.append({'type': bundle.type, 'gauge': bundle.gauge, 'gauge_unit': bundle.gauge_unit, 'length': bundle.length, 'color': color, 'designator': bundle.name, - 'manufacturer': remove_line_breaks(index_if_list(bundle.manufacturer, index)), - 'mpn': remove_line_breaks(index_if_list(bundle.mpn, index)), - 'pn': index_if_list(bundle.pn, index)}) - # join similar wires from all the bundles to a single BOM item - wire_group = lambda w: (w.get('type', None), w['gauge'], w['gauge_unit'], w['color'], w['manufacturer'], w['mpn'], w['pn']) - for group in Counter([wire_group(v) for v in wirelist]): - items = [v for v in wirelist if wire_group(v) == group] - shared = items[0] - designators = [i['designator'] for i in items] + for cable in self.cables.values(): + if not cable.ignore_in_bom: + if cable.category != 'bundle': + # process cable as a single entity + description = ('Cable' + + (f', {cable.type}' if cable.type else '') + + (f', {cable.wirecount}') + + (f' x {cable.gauge} {cable.gauge_unit}' if cable.gauge else ' wires') + + (' shielded' if cable.shield else '')) + bom_entries.append({ + 'item': description, 'qty': cable.length, 'unit': 'm', 'designators': cable.name if cable.show_name else None, + 'manufacturer': cable.manufacturer, 'mpn': cable.mpn, 'pn': cable.pn + }) + else: + # add each wire from the bundle to the bom + for index, color in enumerate(cable.colors): + description = ('Wire' + + (f', {cable.type}' if cable.type else '') + + (f', {cable.gauge} {cable.gauge_unit}' if cable.gauge else '') + + (f', {color}' if color else '')) + bom_entries.append({ + 'item': description, 'qty': cable.length, 'unit': 'm', 'designators': cable.name if cable.show_name else None, + 'manufacturer': index_if_list(cable.manufacturer, index), + 'mpn': index_if_list(cable.mpn, index), 'pn': index_if_list(cable.pn, index) + }) + + # add cable/bundles aditional components to bom + bom_entries.extend(self.get_additional_component_bom(cable)) + + for item in self.additional_bom_items: + bom_entries.append({ + 'item': item.get('description', ''), 'qty': item.get('qty', 1), 'unit': item.get('unit'), 'designators': item.get('designators'), + 'manufacturer': item.get('manufacturer'), 'mpn': item.get('mpn'), 'pn': item.get('pn') + }) + + # remove line breaks if present and cleanup any resulting whitespace issues + bom_entries = [{k: clean_whitespace(v) for k, v in entry.items()} for entry in bom_entries] + + # deduplicate bom + bom_types_group = lambda bt: (bt['item'], bt['unit'], bt['manufacturer'], bt['mpn'], bt['pn']) + for group in Counter([bom_types_group(v) for v in bom_entries]): + group_entries = [v for v in bom_entries if bom_types_group(v) == group] + designators = [] + for group_entry in group_entries: + if group_entry.get('designators'): + if isinstance(group_entry['designators'], List): + designators.extend(group_entry['designators']) + else: + designators.append(group_entry['designators']) designators = list(dict.fromkeys(designators)) # remove duplicates designators.sort() - total_length = sum(i['length'] for i in items) - wire_type = f', {remove_line_breaks(shared["type"])}' if shared.get('type', None) else '' - gauge_name = f', {shared["gauge"]} {shared["gauge_unit"]}' if shared.get('gauge', None) else '' - gauge_color = f', {shared["color"]}' if 'color' in shared != '' else '' - name = f'Wire{wire_type}{gauge_name}{gauge_color}' - item = {'item': name, 'qty': round(total_length, 3), 'unit': 'm', 'designators': designators, - 'manufacturer': shared['manufacturer'], 'mpn': shared['mpn'], 'pn': shared['pn']} - bom_cables.append(item) - bom_cables = sorted(bom_cables, key=lambda k: k['item']) # sort list of dicts by their values (https://stackoverflow.com/a/73050) - bom.extend(bom_cables) + total_qty = sum(entry['qty'] for entry in group_entries) + self._bom.append({**group_entries[0], 'qty': round(total_qty, 3), 'designators': designators}) - for item in self.additional_bom_items: - name = item['description'] if item.get('description', None) else '' - if isinstance(item.get('designators', None), List): - item['designators'].sort() # sort designators if a list is provided - item = {'item': name, 'qty': item.get('qty', None), 'unit': item.get('unit', None), 'designators': item.get('designators', None), - 'manufacturer': item.get('manufacturer', None), 'mpn': item.get('mpn', None), 'pn': item.get('pn', None)} - bom_extra.append(item) - bom_extra = sorted(bom_extra, key=lambda k: k['item']) - bom.extend(bom_extra) - return bom + self._bom = sorted(self._bom, key=lambda k: k['item']) # sort list of dicts by their values (https://stackoverflow.com/a/73050) + + # add an incrementing id to each bom item + self._bom = [{**entry, 'id': index} for index, entry in enumerate(self._bom, 1)] + return self._bom + + def get_bom_index(self, item, unit, manufacturer, mpn, pn): + # Remove linebreaks and clean whitespace of values in search + target = tuple(clean_whitespace(v) for v in (item, unit, manufacturer, mpn, pn)) + for entry in self.bom(): + if (entry['item'], entry['unit'], entry['manufacturer'], entry['mpn'], entry['pn']) == target: + return entry['id'] + return None def bom_list(self): bom = self.bom() - keys = ['item', 'qty', 'unit', 'designators'] # these BOM columns will always be included + keys = ['id', 'item', 'qty', 'unit', 'designators'] # these BOM columns will always be included for fieldname in ['pn', 'manufacturer', 'mpn']: # these optional BOM columns will only be included if at least one BOM item actually uses them - if any(fieldname in x and x.get(fieldname, None) for x in bom): + if any(entry.get(fieldname) for entry in bom): keys.append(fieldname) bom_list = [] # list of staic bom header names, headers not specified here are generated by capitilising the internal name diff --git a/src/wireviz/wv_helper.py b/src/wireviz/wv_helper.py index 8385f7e9..79722bd3 100644 --- a/src/wireviz/wv_helper.py +++ b/src/wireviz/wv_helper.py @@ -146,11 +146,8 @@ def index_if_list(value, index): def html_line_breaks(inp): return inp.replace('\n', '
') if isinstance(inp, str) else inp -def graphviz_line_breaks(inp): - return inp.replace('\n', '\\n') if isinstance(inp, str) else inp # \n generates centered new lines. http://www.graphviz.org/doc/info/attrs.html#k:escString - -def remove_line_breaks(inp): - return inp.replace('\n', ' ').strip() if isinstance(inp, str) else inp +def clean_whitespace(inp): + return ' '.join(inp.split()).replace(' ,', ',') if isinstance(inp, str) else inp def open_file_read(filename): # TODO: Intelligently determine encoding @@ -181,3 +178,25 @@ def manufacturer_info_field(manufacturer, mpn): return f'{manufacturer if manufacturer else "MPN"}{": " + str(mpn) if mpn else ""}' else: return None + +def component_table_entry(type, qty, unit=None, pn=None, manufacturer=None, mpn=None): + output = f'{qty}' + if unit: + output += f' {unit}' + output += f' x {type}' + # print an extra line with part and manufacturer information if provided + manufacturer_str = manufacturer_info_field(manufacturer, mpn) + if pn or manufacturer_str: + output += '
' + if pn: + output += f'P/N: {pn}' + if manufacturer_str: + output += ', ' + if manufacturer_str: + output += manufacturer_str + output = html_line_breaks(output) + # format the above output as left aligned text in a single visible cell + # indent is set to two to match the indent in the generated html table + return f'''
+ +
{output}
''' diff --git a/tutorial/tutorial08.md b/tutorial/tutorial08.md index 1fc884e7..87298ab4 100644 --- a/tutorial/tutorial08.md +++ b/tutorial/tutorial08.md @@ -1,6 +1,7 @@ -## Part numbers +## Part numbers and additional components * Part number information can be added to parts * Only provided fields will be added to the diagram and bom * Bundles can have part information specified by wire -* Additional parts can be added to the bom +* Additional parts can be added to components or just to the bom + * quantities of additional components can be multiplied by features from parent connector or cable diff --git a/tutorial/tutorial08.yml b/tutorial/tutorial08.yml index 2568f29b..27cd3102 100644 --- a/tutorial/tutorial08.yml +++ b/tutorial/tutorial08.yml @@ -5,9 +5,17 @@ connectors: subtype: female manufacturer: Molex # set manufacter name mpn: 22013047 # set manufacturer part number + # add a list of additional components to a part (shown in graph) + additional_components: + - + type: Crimp # short identifier used in graph + subtype: Molex KK 254, 22-30 AWG # extra information added to type in bom + qty_multiplier: populated # multipier for quantity (number of populated pins) + manufacturer: Molex # set manufacter name + mpn: 08500030 # set manufacturer part number X2: <<: *template1 # reuse template - pn: CON4 # set an internal part number + pn: CON4 # set an internal part number for just this connector X3: <<: *template1 # reuse template @@ -28,6 +36,14 @@ cables: manufacturer: [WiresCo,WiresCo,WiresCo,WiresCo] # set a manufacter per wire mpn: [W1-YE,W1-BK,W1-BK,W1-RD] pn: [WIRE1,WIRE2,WIRE2,WIRE3] + # add a list of additional components to a part (shown in graph) + additional_components: + - + type: Sleve # short identifier used in graph + subtype: Braided nylon, black, 3mm # extra information added to type in bom + qty_multiplier: length # multipier for quantity (length of cable) + unit: m + pn: SLV-1 connections: @@ -41,7 +57,7 @@ connections: - X3: [1-4] additional_bom_items: - - # define an additional item to add to the bill of materials + - # define an additional item to add to the bill of materials (does not appear in graph) description: Label, pinout information qty: 2 designators: