From 4e4dac8597fe25ad7835614c56d2da29ce3fce88 Mon Sep 17 00:00:00 2001 From: Daniel Rojas Date: Sat, 17 Oct 2020 11:53:11 +0200 Subject: [PATCH 01/38] Bump version to 0.3-dev --- src/wireviz/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wireviz/__init__.py b/src/wireviz/__init__.py index 81e82a06..4347851c 100644 --- a/src/wireviz/__init__.py +++ b/src/wireviz/__init__.py @@ -1,6 +1,6 @@ # Please don't import anything in this file to avoid issues when it is imported in setup.py -__version__ = '0.2' +__version__ = '0.3-dev' CMD_NAME = 'wireviz' # Lower case command and module name APP_NAME = 'WireViz' # Application name in texts meant to be human readable From e85ee5d28588820292d566d9b40f090698121870 Mon Sep 17 00:00:00 2001 From: Tyler Ward Date: Thu, 22 Oct 2020 16:53:33 +0100 Subject: [PATCH 02/38] 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: From e2e8bbfb913902d511a83e88db7316049b5b48ca Mon Sep 17 00:00:00 2001 From: Miklos Marton Date: Wed, 9 Sep 2020 17:34:12 +0200 Subject: [PATCH 03/38] Remove input text hyperlinks except in the HTML BOM GraphViz does not support the a HTML tag when generating the tables for the cables/connectors, so this change will remove these tags for the graph generation. However for the HTML BOM output table these links will be generated. --- examples/ex05.yml | 2 +- src/wireviz/Harness.py | 12 ++++++------ src/wireviz/wv_helper.py | 8 ++++++-- tutorial/tutorial08.yml | 8 ++++---- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/examples/ex05.yml b/examples/ex05.yml index fd776f98..98a1a340 100644 --- a/examples/ex05.yml +++ b/examples/ex05.yml @@ -1,7 +1,7 @@ # daisy chain, variant 1 templates: - &template_con - type: Molex KK 254 + type: 'Molex KK 254' subtype: female pinlabels: [GND, VCC, SCL, SDA] - &template_wire diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index 437ac205..835ed651 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -8,7 +8,7 @@ from wireviz.wv_helper import awg_equiv, mm2_equiv, tuplelist2tsv, \ nested_html_table, flatten2d, index_if_list, html_line_breaks, \ clean_whitespace, open_file_read, open_file_write, html_colorbar, \ - html_image, html_caption, manufacturer_info_field, component_table_entry + html_image, html_caption, manufacturer_info_field, component_table_entry, remove_links from collections import Counter from typing import List, Union from pathlib import Path @@ -92,8 +92,8 @@ def create_graph(self) -> Graph: html = [] - rows = [[connector.name if connector.show_name else None], - [f'P/N: {connector.pn}' if connector.pn else None, + rows = [[remove_links(connector.name) if connector.show_name else None], + [f'P/N: {remove_links(connector.pn)}' if connector.pn else None, html_line_breaks(manufacturer_info_field(connector.manufacturer, connector.mpn))], [html_line_breaks(connector.type), html_line_breaks(connector.subtype), @@ -162,8 +162,8 @@ def create_graph(self) -> Graph: elif cable.gauge_unit.upper() == 'AWG': awg_fmt = f' ({mm2_equiv(cable.gauge)} mm\u00B2)' - rows = [[cable.name if cable.show_name else None], - [f'P/N: {cable.pn}' if (cable.pn and not isinstance(cable.pn, list)) else None, + rows = [[remove_links(cable.name) if cable.show_name else None], + [f'P/N: {remove_links(cable.pn)}' if (cable.pn and not isinstance(cable.pn, list)) else None, html_line_breaks(manufacturer_info_field( cable.manufacturer if not isinstance(cable.manufacturer, list) else None, cable.mpn if not isinstance(cable.mpn, list) else None))], @@ -205,7 +205,7 @@ def create_graph(self) -> Graph: # create a list of wire parameters wireidentification = [] if isinstance(cable.pn, list): - wireidentification.append(f'P/N: {cable.pn[i - 1]}') + wireidentification.append(f'P/N: {remove_links(cable.pn[i - 1])}') manufacturer_info = manufacturer_info_field( cable.manufacturer[i - 1] if isinstance(cable.manufacturer, list) else None, cable.mpn[i - 1] if isinstance(cable.mpn, list) else None) diff --git a/src/wireviz/wv_helper.py b/src/wireviz/wv_helper.py index 79722bd3..9d785229 100644 --- a/src/wireviz/wv_helper.py +++ b/src/wireviz/wv_helper.py @@ -3,6 +3,7 @@ from wireviz import wv_colors from typing import List +import re awg_equiv_table = { '0.09': '28', @@ -136,15 +137,18 @@ def tuplelist2tsv(inp, header=None): inp.insert(0, header) inp = flatten2d(inp) for row in inp: - output = output + '\t'.join(str(item) for item in row) + '\n' + output = output + '\t'.join(str(remove_links(item)) for item in row) + '\n' return output # Return the value indexed if it is a list, or simply the value otherwise. def index_if_list(value, index): return value[index] if isinstance(value, list) else value +def remove_links(inp): + return re.sub(r'<[aA] [^>]*>([^<]*)', r'\1', inp) if isinstance(inp, str) else inp + def html_line_breaks(inp): - return inp.replace('\n', '
') if isinstance(inp, str) else inp + return remove_links(inp).replace('\n', '
') if isinstance(inp, str) else inp def clean_whitespace(inp): return ' '.join(inp.split()).replace(' ,', ',') if isinstance(inp, str) else inp diff --git a/tutorial/tutorial08.yml b/tutorial/tutorial08.yml index 27cd3102..2dc55443 100644 --- a/tutorial/tutorial08.yml +++ b/tutorial/tutorial08.yml @@ -3,8 +3,8 @@ connectors: type: Molex KK 254 pincount: 4 subtype: female - manufacturer: Molex # set manufacter name - mpn: 22013047 # set manufacturer part number + 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: - @@ -63,6 +63,6 @@ additional_bom_items: designators: - X2 - X3 - manufacturer: generic company - mpn: Label1 + manufacturer: 'Brady' + mpn: 'B-499' pn: Label-ID-1 From 3f091bb419df20ff24ee23b0cd2fddfacf40d12b Mon Sep 17 00:00:00 2001 From: KV Date: Tue, 1 Sep 2020 18:09:01 +0200 Subject: [PATCH 04/38] Update the types of dataclass attributes according to usage Fixes #156 --- src/wireviz/DataClasses.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index e813fbe3..529ad308 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -79,8 +79,8 @@ class Connector: pinlabels: List[Any] = field(default_factory=list) pins: List[Any] = field(default_factory=list) color: Optional[str] = None - show_name: bool = None - show_pincount: bool = None + show_name: Optional[bool] = None + show_pincount: Optional[bool] = None hide_disconnected_pins: bool = False autogenerate: bool = False loops: List[Any] = field(default_factory=list) @@ -167,7 +167,7 @@ class Cable: length: float = 0 color: Optional[str] = None wirecount: Optional[int] = None - shield: bool = False + shield: Union[bool, str] = False # False | True | color image: Optional[Image] = None notes: Optional[str] = None colors: List[Any] = field(default_factory=list) From 64bd34a7c6e115a474dd25000ee4ee15f42977fb Mon Sep 17 00:00:00 2001 From: KV Date: Sun, 6 Sep 2020 20:58:03 +0200 Subject: [PATCH 05/38] Add type aliases that reflect their semantics Using Any or str in type annotations might increase the need for extra comments to explain the real valid values. However, such needs can be drastically reduced with the help of semanticly named type aliases. Each type alias have their legal values described in comments. Actual validation might be implemented in the future. --- src/wireviz/DataClasses.py | 100 ++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 40 deletions(-) diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index 529ad308..a9ce303f 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -1,15 +1,32 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -from typing import Optional, List, Any, Union +from typing import Optional, List, Tuple, Union from dataclasses import dataclass, field, InitVar from pathlib import Path from wireviz.wv_helper import int2tuple, aspect_ratio from wireviz import wv_colors + +# Each type alias have their legal values described in comments - validation might be implemented in the future +PlainText = str # Text not containing HTML tags nor newlines +Hypertext = str # Text possibly including HTML hyperlinks that are removed in all outputs except HTML output +MultilineHypertext = str # Hypertext possibly also including newlines to break lines in diagram output +Designator = PlainText # Case insensitive unique name of connector or cable + # 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'] +ConnectorMultiplier = PlainText # = Literal['pincount', 'populated'] +CableMultiplier = PlainText # = Literal['wirecount', 'terminations', 'length', 'total_length'] +ImageScale = PlainText # = Literal['false', 'true', 'width', 'height', 'both'] +Color = PlainText # Two-letter color name = Literal[wv_colors._color_hex.keys()] +ColorScheme = PlainText # Color scheme name = Literal[wv_colors.COLOR_CODES.keys()] + +# Type combinations +Colors = PlainText # One or more two-letter color names (Color) concatenated into one string +Pin = Union[int, PlainText] # Pin identifier +Wire = Union[int, PlainText] # Wire number or Literal['s'] for shield +NoneOrMorePins = Union[Pin, Tuple[Pin, ...], None] # None, one, or a tuple of pins +OneOrMoreWires = Union[Wire, Tuple[Wire, ...]] # One or a tuple of wires @dataclass @@ -17,13 +34,13 @@ class Image: gv_dir: InitVar[Path] # Directory of .gv file injected as context during parsing # Attributes of the image object : src: str - scale: Optional[str] = None # false | true | width | height | both + scale: Optional[ImageScale] = None # Attributes of the image cell containing the image: width: Optional[int] = None height: Optional[int] = None fixedsize: Optional[bool] = None # Contents of the text cell just below the image cell: - caption: Optional[str] = None + caption: Optional[MultilineHypertext] = None # See also HTML doc at https://graphviz.org/doc/info/shapes.html#html def __post_init__(self, gv_dir): @@ -47,13 +64,14 @@ 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 + type: MultilineHypertext + subtype: Optional[MultilineHypertext] = None + manufacturer: Optional[MultilineHypertext] = None + mpn: Optional[MultilineHypertext] = None + pn: Optional[Hypertext] = None qty: float = 1 unit: Optional[str] = None qty_multiplier: Union[ConnectorMultiplier, CableMultiplier, None] = None @@ -65,29 +83,29 @@ def description(self) -> str: @dataclass class Connector: - name: str - manufacturer: Optional[str] = None - mpn: Optional[str] = None - pn: Optional[str] = None + name: Designator + manufacturer: Optional[MultilineHypertext] = None + mpn: Optional[MultilineHypertext] = None + pn: Optional[Hypertext] = None style: Optional[str] = None category: Optional[str] = None - type: Optional[str] = None - subtype: Optional[str] = None + type: Optional[MultilineHypertext] = None + subtype: Optional[MultilineHypertext] = None pincount: Optional[int] = None image: Optional[Image] = None - notes: Optional[str] = None - pinlabels: List[Any] = field(default_factory=list) - pins: List[Any] = field(default_factory=list) - color: Optional[str] = None + notes: Optional[MultilineHypertext] = None + pinlabels: List[Pin] = field(default_factory=list) + pins: List[Pin] = field(default_factory=list) + color: Optional[Color] = None show_name: Optional[bool] = None show_pincount: Optional[bool] = None hide_disconnected_pins: bool = False autogenerate: bool = False - loops: List[Any] = field(default_factory=list) + loops: List[List[Pin]] = field(default_factory=list) ignore_in_bom: bool = False additional_components: List[AdditionalComponent] = field(default_factory=list) - def __post_init__(self): + def __post_init__(self) -> None: if isinstance(self.image, dict): self.image = Image(**self.image) @@ -139,7 +157,7 @@ def __post_init__(self): if isinstance(item, dict): self.additional_components[i] = AdditionalComponent(**item) - def activate_pin(self, pin): + def activate_pin(self, pin: Pin) -> None: self.visible_pins[pin] = True def get_qty_multiplier(self, qty_multiplier: Optional[ConnectorMultiplier]) -> int: @@ -155,29 +173,29 @@ def get_qty_multiplier(self, qty_multiplier: Optional[ConnectorMultiplier]) -> i @dataclass class Cable: - name: str - manufacturer: Optional[Union[str, List[str]]] = None - mpn: Optional[Union[str, List[str]]] = None - pn: Optional[Union[str, List[str]]] = None + name: Designator + manufacturer: Union[MultilineHypertext, List[MultilineHypertext], None] = None + mpn: Union[MultilineHypertext, List[MultilineHypertext], None] = None + pn: Union[Hypertext, List[Hypertext], None] = None category: Optional[str] = None - type: Optional[str] = None + type: Optional[MultilineHypertext] = None gauge: Optional[float] = None gauge_unit: Optional[str] = None show_equiv: bool = False length: float = 0 - color: Optional[str] = None + color: Optional[Color] = None wirecount: Optional[int] = None - shield: Union[bool, str] = False # False | True | color + shield: Union[bool, Color] = False image: Optional[Image] = None - notes: Optional[str] = None - colors: List[Any] = field(default_factory=list) - color_code: Optional[str] = None + notes: Optional[MultilineHypertext] = None + colors: List[Colors] = field(default_factory=list) + color_code: Optional[ColorScheme] = 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): + def __post_init__(self) -> None: if isinstance(self.image, dict): self.image = Image(**self.image) @@ -237,7 +255,9 @@ def __post_init__(self): if isinstance(item, dict): self.additional_components[i] = AdditionalComponent(**item) - def connect(self, from_name, from_pin, via_pin, to_name, to_pin): + # The *_pin arguments accept a tuple, but it seems not in use with the current code. + def connect(self, from_name: Optional[Designator], from_pin: NoneOrMorePins, via_pin: OneOrMoreWires, + to_name: Optional[Designator], to_pin: NoneOrMorePins) -> None: from_pin = int2tuple(from_pin) via_pin = int2tuple(via_pin) to_pin = int2tuple(to_pin) @@ -264,8 +284,8 @@ def get_qty_multiplier(self, qty_multiplier: Optional[CableMultiplier]) -> float @dataclass class Connection: - from_name: Any - from_port: Any - via_port: Any - to_name: Any - to_port: Any + from_name: Optional[Designator] + from_port: Optional[Pin] + via_port: Wire + to_name: Optional[Designator] + to_port: Optional[Pin] From feff47f47b9c0f7121544699bb0b092b563a8e74 Mon Sep 17 00:00:00 2001 From: Daniel Rojas Date: Sat, 14 Nov 2020 09:43:01 +0100 Subject: [PATCH 06/38] Add option to add colors to connector pins (#141) --- src/wireviz/DataClasses.py | 20 ++++++-------------- src/wireviz/Harness.py | 14 +++++++++++++- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index a9ce303f..35003075 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -96,6 +96,7 @@ class Connector: notes: Optional[MultilineHypertext] = None pinlabels: List[Pin] = field(default_factory=list) pins: List[Pin] = field(default_factory=list) + pincolors: List[Color] = field(default_factory=list) color: Optional[Color] = None show_name: Optional[bool] = None show_pincount: Optional[bool] = None @@ -119,23 +120,14 @@ def __post_init__(self) -> None: raise Exception('Connectors with style set to simple may only have one pin') self.pincount = 1 - if self.pincount is None: - if self.pinlabels: - self.pincount = len(self.pinlabels) - elif self.pins: - self.pincount = len(self.pins) - else: - raise Exception('You need to specify at least one, pincount, pins or pinlabels') - - if self.pinlabels and self.pins: - if len(self.pinlabels) != len(self.pins): - raise Exception('Given pins and pinlabels size mismatch') + if not self.pincount: + self.pincount = max(len(self.pins), len(self.pinlabels), len(self.pincolors)) + if not self.pincount: + raise Exception('You need to specify at least one, pincount, pins, pinlabels, or pincolors') - # create default lists for pins (sequential) and pinlabels (blank) if not specified + # create default list for pins (sequential) if not specified if not self.pins: self.pins = list(range(1, self.pincount + 1)) - if not self.pinlabels: - self.pinlabels = [''] * self.pincount if len(self.pins) != len(set(self.pins)): raise Exception('Pins are not unique') diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index 835ed651..be8cf1a5 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -12,6 +12,7 @@ from collections import Counter from typing import List, Union from pathlib import Path +from itertools import zip_longest import re @@ -110,7 +111,7 @@ def create_graph(self) -> Graph: pinhtml = [] pinhtml.append('') - for pin, pinlabel in zip(connector.pins, connector.pinlabels): + for pin, pinlabel, pincolor in zip_longest(connector.pins, connector.pinlabels, connector.pincolors): if connector.hide_disconnected_pins and not connector.visible_pins.get(pin, False): continue pinhtml.append(' ') @@ -118,6 +119,17 @@ def create_graph(self) -> Graph: pinhtml.append(f' ') if pinlabel: pinhtml.append(f' ') + if connector.pincolors: + if pincolor in wv_colors._color_hex.keys(): + pinhtml.append(f' ') + pinhtml.append( ' ') + else: + pinhtml.append( ' ') + if connector.ports_right: pinhtml.append(f' ') pinhtml.append(' ') From 03e60775127683155f4c83e5b32f92f9e315c14f Mon Sep 17 00:00:00 2001 From: Daniel Rojas Date: Wed, 11 Nov 2020 16:13:47 +0100 Subject: [PATCH 07/38] Allow referencing wires by color/label (#169,#193) --- src/wireviz/DataClasses.py | 17 ++++++++--- src/wireviz/Harness.py | 60 ++++++++++++++++++++++++++++---------- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index 35003075..7f01ffdf 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -181,9 +181,11 @@ class Cable: image: Optional[Image] = None notes: Optional[MultilineHypertext] = None colors: List[Colors] = field(default_factory=list) + wirelabels: List[Wire] = field(default_factory=list) color_code: Optional[ColorScheme] = None show_name: bool = True show_wirecount: bool = True + show_wirenumbers: Optional[bool] = None ignore_in_bom: bool = False additional_components: List[AdditionalComponent] = field(default_factory=list) @@ -233,6 +235,10 @@ def __post_init__(self) -> None: raise Exception('Unknown number of wires. Must specify wirecount or colors (implicit length)') self.wirecount = len(self.colors) + if self.wirelabels: + if self.shield and 's' in self.wirelabels: + raise Exception('"s" may not be used as a wire label for a shielded cable.') + # if lists of part numbers are provided check this is a bundle and that it matches the wirecount. for idfield in [self.manufacturer, self.mpn, self.pn]: if isinstance(idfield, list): @@ -243,21 +249,24 @@ def __post_init__(self) -> None: else: raise Exception('lists of part data are only supported for bundles') + # by default, show wire numbers for cables, hide for bundles + if not self.show_wirenumbers: + self.show_wirenumbers = self.category != 'bundle' + for i, item in enumerate(self.additional_components): if isinstance(item, dict): self.additional_components[i] = AdditionalComponent(**item) # The *_pin arguments accept a tuple, but it seems not in use with the current code. - def connect(self, from_name: Optional[Designator], from_pin: NoneOrMorePins, via_pin: OneOrMoreWires, + def connect(self, from_name: Optional[Designator], from_pin: NoneOrMorePins, via_wire: OneOrMoreWires, to_name: Optional[Designator], to_pin: NoneOrMorePins) -> None: from_pin = int2tuple(from_pin) - via_pin = int2tuple(via_pin) + via_wire = int2tuple(via_wire) to_pin = int2tuple(to_pin) if len(from_pin) != len(to_pin): raise Exception('from_pin must have the same number of elements as to_pin') for i, _ in enumerate(from_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])) + self.connections.append(Connection(from_name, from_pin[i], via_wire[i], to_name, to_pin[i])) def get_qty_multiplier(self, qty_multiplier: Optional[CableMultiplier]) -> float: if not qty_multiplier: diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index be8cf1a5..42593ab2 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -35,30 +35,46 @@ def add_cable(self, name: str, *args, **kwargs) -> None: def add_bom_item(self, item: dict) -> None: self.additional_bom_items.append(item) - def connect(self, from_name: str, from_pin: (int, str), via_name: str, via_pin: (int, str), to_name: str, to_pin: (int, str)) -> None: - for (name, pin) in zip([from_name, to_name], [from_pin, to_pin]): # check from and to connectors + def connect(self, from_name: str, from_pin: (int, str), via_name: str, via_wire: (int, str), to_name: str, to_pin: (int, str)) -> None: + # check from and to connectors + for (name, pin) in zip([from_name, to_name], [from_pin, to_pin]): if name is not None and name in self.connectors: connector = self.connectors[name] + # check if provided name is ambiguous if pin in connector.pins and pin in connector.pinlabels: - if connector.pins.index(pin) == connector.pinlabels.index(pin): - # TODO: Maybe issue a warning? It's not worthy of an exception if it's unambiguous, but maybe risky? - pass - else: + if connector.pins.index(pin) != connector.pinlabels.index(pin): raise Exception(f'{name}:{pin} is defined both in pinlabels and pins, for different pins.') + # TODO: Maybe issue a warning if present in both lists but referencing the same pin? if pin in connector.pinlabels: if connector.pinlabels.count(pin) > 1: raise Exception(f'{name}:{pin} is defined more than once.') - else: - index = connector.pinlabels.index(pin) - pin = connector.pins[index] # map pin name to pin number - if name == from_name: - from_pin = pin - if name == to_name: - to_pin = pin + index = connector.pinlabels.index(pin) + pin = connector.pins[index] # map pin name to pin number + if name == from_name: + from_pin = pin + if name == to_name: + to_pin = pin if not pin in connector.pins: raise Exception(f'{name}:{pin} not found.') - self.cables[via_name].connect(from_name, from_pin, via_pin, to_name, to_pin) + # check via cable + if via_name in self.cables: + cable = self.cables[via_name] + # check if provided name is ambiguous + if via_wire in cable.colors and via_wire in cable.wirelabels: + if cable.colors.index(via_wire) != cable.wirelabels.index(via_wire): + raise Exception(f'{via_name}:{via_wire} is defined both in colors and wirelabels, for different wires.') + # TODO: Maybe issue a warning if present in both lists but referencing the same wire? + if via_wire in cable.colors: + if cable.colors.count(via_wire) > 1: + raise Exception(f'{via_name}:{via_wire} is used for more than one wire.') + via_wire = cable.colors.index(via_wire) + 1 # list index starts at 0, wire IDs start at 1 + elif via_wire in cable.wirelabels: + if cable.wirelabels.count(via_wire) > 1: + raise Exception(f'{via_name}:{via_wire} is used for more than one wire.') + via_wire = cable.wirelabels.index(via_wire) + 1 # list index starts at 0, wire IDs start at 1 + + self.cables[via_name].connect(from_name, from_pin, via_wire, to_name, to_pin) if from_name in self.connectors: self.connectors[from_name].activate_pin(from_pin) if to_name in self.connectors: @@ -197,10 +213,22 @@ def create_graph(self) -> Graph: wirehtml.append('
{pin}{pinlabel}{pincolor}') + pinhtml.append( ' ') + pinhtml.append(f' ') + pinhtml.append( '
') + pinhtml.append( '
{pin}
') # conductor table wirehtml.append(' ') - for i, connection_color in enumerate(cable.colors, 1): + for i, (connection_color, wirelabel) in enumerate(zip_longest(cable.colors, cable.wirelabels), 1): wirehtml.append(' ') wirehtml.append(f' ') - wirehtml.append(f' ') + wirehtml.append(f' ') wirehtml.append(f' ') wirehtml.append(' ') From 3bf448c692825310ad756a6241c75aa0365bce8e Mon Sep 17 00:00:00 2001 From: Daniel Rojas Date: Wed, 11 Nov 2020 16:39:56 +0100 Subject: [PATCH 08/38] Update example 08 to reference wires using colors --- examples/ex08.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/ex08.yml b/examples/ex08.yml index eea3976a..a17583f4 100644 --- a/examples/ex08.yml +++ b/examples/ex08.yml @@ -28,7 +28,7 @@ cables: connections: - - Key: [S,R,T] - - W1: [1,2,3] + - W1: [WH,BN,GN] - - Key: S - W1: s From eebf932c8dba741c35f8e12a11ac52f0b5dd5d2a Mon Sep 17 00:00:00 2001 From: Daniel Rojas Date: Wed, 11 Nov 2020 20:59:27 +0100 Subject: [PATCH 09/38] Show pin labels of adjacent connectors in cable node (#70) --- src/wireviz/Harness.py | 48 +++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index 42593ab2..8973788d 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -282,26 +282,44 @@ def create_graph(self) -> Graph: html = [row.replace('', '\n'.join(wirehtml)) for row in html] # connections - for connection_color in cable.connections: - if isinstance(connection_color.via_port, int): # check if it's an actual wire and not a shield - dot.attr('edge', color=':'.join(['#000000'] + wv_colors.get_color_hex(cable.colors[connection_color.via_port - 1], pad=pad) + ['#000000'])) + for connection in cable.connections: + if isinstance(connection.via_port, int): # check if it's an actual wire and not a shield + dot.attr('edge', color=':'.join(['#000000'] + wv_colors.get_color_hex(cable.colors[connection.via_port - 1], pad=pad) + ['#000000'])) else: # it's a shield connection # shield is shown with specified color and black borders, or as a thin black wire otherwise dot.attr('edge', color=':'.join(['#000000', shield_color_hex, '#000000']) if isinstance(cable.shield, str) else '#000000') - if connection_color.from_port is not None: # connect to left - from_port = f':p{connection_color.from_port}r' if self.connectors[connection_color.from_name].style != 'simple' else '' - code_left_1 = f'{connection_color.from_name}{from_port}:e' - code_left_2 = f'{cable.name}:w{connection_color.via_port}:w' + if connection.from_port is not None: # connect to left + from_connector = self.connectors[connection.from_name] + from_port = f':p{connection.from_port}r' if from_connector.style != 'simple' else '' + code_left_1 = f'{connection.from_name}{from_port}:e' + code_left_2 = f'{cable.name}:w{connection.via_port}:w' dot.edge(code_left_1, code_left_2) - from_string = f'{connection_color.from_name}:{connection_color.from_port}' if self.connectors[connection_color.from_name].show_name else '' - html = [row.replace(f'', from_string) for row in html] - if connection_color.to_port is not None: # connect to right - code_right_1 = f'{cable.name}:w{connection_color.via_port}:e' - to_port = f':p{connection_color.to_port}l' if self.connectors[connection_color.to_name].style != 'simple' else '' - code_right_2 = f'{connection_color.to_name}{to_port}:w' + if from_connector.show_name: + from_info = [str(connection.from_name), str(connection.from_port)] + if from_connector.pinlabels: + pinlabel = from_connector.pinlabels[from_connector.pins.index(connection.from_port)] + if pinlabel != '': + from_info.append(pinlabel) + from_string = ':'.join(from_info) + else: + from_string = '' + html = [row.replace(f'', from_string) for row in html] + if connection.to_port is not None: # connect to right + to_connector = self.connectors[connection.to_name] + code_right_1 = f'{cable.name}:w{connection.via_port}:e' + to_port = f':p{connection.to_port}l' if self.connectors[connection.to_name].style != 'simple' else '' + code_right_2 = f'{connection.to_name}{to_port}:w' dot.edge(code_right_1, code_right_2) - to_string = f'{connection_color.to_name}:{connection_color.to_port}' if self.connectors[connection_color.to_name].show_name else '' - html = [row.replace(f'', to_string) for row in html] + if to_connector.show_name: + to_info = [str(connection.to_name), str(connection.to_port)] + if to_connector.pinlabels: + pinlabel = to_connector.pinlabels[to_connector.pins.index(connection.to_port)] + if pinlabel != '': + to_info.append(pinlabel) + to_string = ':'.join(to_info) + else: + to_string = '' + html = [row.replace(f'', to_string) for row in html] html = '\n'.join(html) dot.node(cable.name, label=f'<\n{html}\n>', shape='box', From 96bd121403b06855fcddcc93330d393519e70389 Mon Sep 17 00:00:00 2001 From: Daniel Rojas Date: Sun, 1 Nov 2020 12:30:31 +0100 Subject: [PATCH 10/38] Create separate modules for BOM and HTML functions --- src/wireviz/DataClasses.py | 1 + src/wireviz/Harness.py | 200 ++++--------------------------------- src/wireviz/wv_bom.py | 178 +++++++++++++++++++++++++++++++++ src/wireviz/wv_gv_html.py | 66 ++++++++++++ src/wireviz/wv_helper.py | 89 +---------------- src/wireviz/wv_html.py | 44 ++++++++ 6 files changed, 308 insertions(+), 270 deletions(-) create mode 100644 src/wireviz/wv_bom.py create mode 100644 src/wireviz/wv_gv_html.py create mode 100644 src/wireviz/wv_html.py diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index 7f01ffdf..48be7062 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -4,6 +4,7 @@ from typing import Optional, List, Tuple, Union from dataclasses import dataclass, field, InitVar from pathlib import Path + from wireviz.wv_helper import int2tuple, aspect_ratio from wireviz import wv_colors diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index 8973788d..352f93b2 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -1,20 +1,24 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from wireviz.DataClasses import Connector, Cable from graphviz import Graph -from wireviz import wv_colors, wv_helper, __version__, APP_NAME, APP_URL -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, \ - clean_whitespace, open_file_read, open_file_write, html_colorbar, \ - html_image, html_caption, manufacturer_info_field, component_table_entry, remove_links from collections import Counter from typing import List, Union from pathlib import Path from itertools import zip_longest import re +from wireviz import wv_colors, __version__, APP_NAME, APP_URL +from wireviz.DataClasses import Connector, Cable +from wireviz.wv_colors import get_color_hex +from wireviz.wv_gv_html import nested_html_table, html_colorbar, html_image, \ + html_caption, remove_links, html_line_breaks +from wireviz.wv_bom import manufacturer_info_field, component_table_entry, \ + get_additional_component_table, bom_list, generate_bom +from wireviz.wv_html import generate_html_output +from wireviz.wv_helper import awg_equiv, mm2_equiv, tuplelist2tsv, flatten2d, \ + open_file_read, open_file_write + class Harness: @@ -119,7 +123,7 @@ def create_graph(self) -> Graph: '' if connector.style != 'simple' else None, [html_image(connector.image)], [html_caption(connector.image)]] - rows.extend(self.get_additional_component_table(connector)) + rows.extend(get_additional_component_table(self, connector)) rows.append([html_line_breaks(connector.notes)]) html.extend(nested_html_table(rows)) @@ -205,7 +209,7 @@ def create_graph(self) -> Graph: [html_image(cable.image)], [html_caption(cable.image)]] - rows.extend(self.get_additional_component_table(cable)) + rows.extend(get_additional_component_table(self, cable)) rows.append([html_line_breaks(cable.notes)]) html.extend(nested_html_table(rows)) @@ -353,181 +357,13 @@ def output(self, filename: (str, Path), view: bool = False, cleanup: bool = True graph.render(filename=filename, view=view, cleanup=cleanup) graph.save(filename=f'{filename}.gv') # bom output - bom_list = self.bom_list() + bomlist = bom_list(self.bom()) with open_file_write(f'{filename}.bom.tsv') as file: - file.write(tuplelist2tsv(bom_list)) + file.write(tuplelist2tsv(bomlist)) # HTML output - with open_file_write(f'{filename}.html') as file: - file.write('\n') - file.write('\n') - file.write(' \n') - file.write(f' \n') - file.write(f' {APP_NAME} Diagram and BOM\n') - file.write('\n') - - file.write('

Diagram

') - with open_file_read(f'{filename}.svg') as svg: - file.write(re.sub( - '^<[?]xml [^?>]*[?]>[^<]*]*>', - '', - svg.read(1024), 1)) - for svgdata in svg: - file.write(svgdata) - - file.write('

Bill of Materials

') - listy = flatten2d(bom_list) - file.write('
 
{wv_colors.translate_color(connection_color, self.color_mode)}') + + wireinfo = [] + if cable.show_wirenumbers: + wireinfo.append(str(i)) + colorstr = wv_colors.translate_color(connection_color, self.color_mode) + if colorstr: + wireinfo.append(colorstr) + if cable.wirelabels: + wireinfo.append(wirelabel if wirelabel is not None else '') + wirehtml.append(f' {":".join(wireinfo)}') + + wirehtml.append(f'
') - file.write('') - for item in listy[0]: - file.write(f'') - file.write('') - for row in listy[1:]: - file.write('') - for i, item in enumerate(row): - item_str = item.replace('\u00b2', '²') - align = 'text-align:right; ' if listy[0][i] == 'Qty' else '' - file.write(f'') - file.write('') - file.write('
{item}
{item_str}
') - - 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) + generate_html_output(filename, bomlist) def bom(self): - # if the bom has previously been generated then return the generated bom - if self._bom: - return self._bom - bom_entries = [] - - # 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? - 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_qty = sum(entry['qty'] for entry in group_entries) - self._bom.append({**group_entries[0], 'qty': round(total_qty, 3), 'designators': designators}) - - 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)] + if not self._bom: + self._bom = generate_bom(self) 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 = ['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(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 - bom_headings = { - "pn": "P/N", - "mpn": "MPN" - } - bom_list.append([(bom_headings[k] if k in bom_headings else k.capitalize()) for k in keys]) # create header row with keys - for item in bom: - item_list = [item.get(key, '') for key in keys] # fill missing values with blanks - item_list = [', '.join(subitem) if isinstance(subitem, List) else subitem for subitem in item_list] # convert any lists into comma separated strings - item_list = ['' if subitem is None else subitem for subitem in item_list] # if a field is missing for some (but not all) BOM items - bom_list.append(item_list) - return bom_list diff --git a/src/wireviz/wv_bom.py b/src/wireviz/wv_bom.py new file mode 100644 index 00000000..a854ac86 --- /dev/null +++ b/src/wireviz/wv_bom.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from typing import List, Union +from collections import Counter + +from wireviz.DataClasses import Connector, Cable +from wireviz.wv_gv_html import html_line_breaks +from wireviz.wv_helper import clean_whitespace + +def get_additional_component_table(harness, 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 harness.mini_bom_mode: + id = get_bom_index(harness, 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(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 generate_bom(harness): + from wireviz.Harness import Harness # Local import to avoid circular imports + bom_entries = [] + # connectors + for connector in harness.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(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? + for cable in harness.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(get_additional_component_bom(cable)) + + for item in harness.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 = [] + 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_qty = sum(entry['qty'] for entry in group_entries) + bom.append({**group_entries[0], 'qty': round(total_qty, 3), 'designators': designators}) + + bom = sorted(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 + bom = [{**entry, 'id': index} for index, entry in enumerate(bom, 1)] + return bom + +def get_bom_index(harness, 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 harness.bom(): + if (entry['item'], entry['unit'], entry['manufacturer'], entry['mpn'], entry['pn']) == target: + return entry['id'] + return None + +def bom_list(bom): + 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(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 + bom_headings = { + "pn": "P/N", + "mpn": "MPN" + } + bom_list.append([(bom_headings[k] if k in bom_headings else k.capitalize()) for k in keys]) # create header row with keys + for item in bom: + item_list = [item.get(key, '') for key in keys] # fill missing values with blanks + item_list = [', '.join(subitem) if isinstance(subitem, List) else subitem for subitem in item_list] # convert any lists into comma separated strings + item_list = ['' if subitem is None else subitem for subitem in item_list] # if a field is missing for some (but not all) BOM items + bom_list.append(item_list) + return bom_list + +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}
''' + +def manufacturer_info_field(manufacturer, mpn): + if manufacturer or mpn: + return f'{manufacturer if manufacturer else "MPN"}{": " + str(mpn) if mpn else ""}' + else: + return None + +# Return the value indexed if it is a list, or simply the value otherwise. +def index_if_list(value, index): + return value[index] if isinstance(value, list) else value diff --git a/src/wireviz/wv_gv_html.py b/src/wireviz/wv_gv_html.py new file mode 100644 index 00000000..ee5b2e28 --- /dev/null +++ b/src/wireviz/wv_gv_html.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from typing import List, Union +import re + +from wireviz.wv_colors import translate_color +from wireviz.wv_helper import remove_links + +def nested_html_table(rows): + # input: list, each item may be scalar or list + # output: a parent table with one child table per parent item that is list, and one cell per parent item that is scalar + # purpose: create the appearance of one table, where cell widths are independent between rows + # attributes in any leading inside a list are injected into to the preceeding tag + html = [] + html.append('') + for row in rows: + if isinstance(row, List): + if len(row) > 0 and any(row): + html.append(' ') + elif row is not None: + html.append(' ') + html.append('
') + html.append(' ') + for cell in row: + if cell is not None: + # Inject attributes to the preceeding '.replace('>
tag where needed + html.append(f' {cell}
') + html.append('
') + html.append(f' {row}') + html.append('
') + return html + +def html_colorbar(color): + return f'' if color else None + +def html_image(image): + from wireviz.DataClasses import Image + if not image: + return None + # The leading attributes belong to the preceeding tag. See where used below. + html = f'{html_size_attr(image)}>' + if image.fixedsize: + # Close the preceeding tag and enclose the image cell in a table without + # borders to avoid narrow borders when the fixed width < the node width. + html = f'''> + + +
+ ''' + return f'''{html_line_breaks(image.caption)}' if image and image.caption else None + +def html_size_attr(image): + from wireviz.DataClasses import Image + # Return Graphviz HTML attributes to specify minimum or fixed size of a TABLE or TD object + return ((f' width="{image.width}"' if image.width else '') + + (f' height="{image.height}"' if image.height else '') + + ( ' fixedsize="true"' if image.fixedsize else '')) if image else '' + +def html_line_breaks(inp): + return remove_links(inp).replace('\n', '
') if isinstance(inp, str) else inp diff --git a/src/wireviz/wv_helper.py b/src/wireviz/wv_helper.py index 9d785229..ffc4bd84 100644 --- a/src/wireviz/wv_helper.py +++ b/src/wireviz/wv_helper.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -from wireviz import wv_colors from typing import List import re @@ -32,58 +31,6 @@ def awg_equiv(mm2): def mm2_equiv(awg): return mm2_equiv_table.get(str(awg), 'Unknown') -def nested_html_table(rows): - # input: list, each item may be scalar or list - # output: a parent table with one child table per parent item that is list, and one cell per parent item that is scalar - # purpose: create the appearance of one table, where cell widths are independent between rows - # attributes in any leading inside a list are injected into to the preceeding tag - html = [] - html.append('') - for row in rows: - if isinstance(row, List): - if len(row) > 0 and any(row): - html.append(' ') - elif row is not None: - html.append(' ') - html.append('
') - html.append(' ') - for cell in row: - if cell is not None: - # Inject attributes to the preceeding '.replace('>
tag where needed - html.append(f' {cell}
') - html.append('
') - html.append(f' {row}') - html.append('
') - return html - -def html_colorbar(color): - return f'' if color else None - -def html_image(image): - if not image: - return None - # The leading attributes belong to the preceeding tag. See where used below. - html = f'{html_size_attr(image)}>' - if image.fixedsize: - # Close the preceeding tag and enclose the image cell in a table without - # borders to avoid narrow borders when the fixed width < the node width. - html = f'''> - - -
- ''' - return f'''{html_line_breaks(image.caption)}' if image and image.caption else None - -def html_size_attr(image): - # Return Graphviz HTML attributes to specify minimum or fixed size of a TABLE or TD object - return ((f' width="{image.width}"' if image.width else '') - + (f' height="{image.height}"' if image.height else '') - + ( ' fixedsize="true"' if image.fixedsize else '')) if image else '' - def expand(yaml_data): # yaml_data can be: @@ -140,19 +87,15 @@ def tuplelist2tsv(inp, header=None): output = output + '\t'.join(str(remove_links(item)) for item in row) + '\n' return output -# Return the value indexed if it is a list, or simply the value otherwise. -def index_if_list(value, index): - return value[index] if isinstance(value, list) else value def remove_links(inp): return re.sub(r'<[aA] [^>]*>([^<]*)', r'\1', inp) if isinstance(inp, str) else inp -def html_line_breaks(inp): - return remove_links(inp).replace('\n', '
') 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 return open(filename, 'r', encoding='UTF-8') @@ -163,7 +106,6 @@ def open_file_write(filename): def open_file_append(filename): return open(filename, 'a', encoding='UTF-8') - def aspect_ratio(image_src): try: from PIL import Image @@ -175,32 +117,3 @@ def aspect_ratio(image_src): except Exception as error: print(f'aspect_ratio(): {type(error).__name__}: {error}') return 1 # Assume 1:1 when unable to read actual image size - - -def manufacturer_info_field(manufacturer, mpn): - if manufacturer or 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/src/wireviz/wv_html.py b/src/wireviz/wv_html.py new file mode 100644 index 00000000..b328ba3d --- /dev/null +++ b/src/wireviz/wv_html.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from pathlib import Path +import re + +from wireviz import __version__, APP_NAME, APP_URL +from wireviz.wv_helper import flatten2d, open_file_read, open_file_write + +def generate_html_output(filename: (str, Path), bom_list): + with open_file_write(f'{filename}.html') as file: + file.write('\n') + file.write('\n') + file.write(' \n') + file.write(f' \n') + file.write(f' {APP_NAME} Diagram and BOM\n') + file.write('\n') + + file.write('

Diagram

') + with open_file_read(f'{filename}.svg') as svg: + file.write(re.sub( + '^<[?]xml [^?>]*[?]>[^<]*]*>', + '', + svg.read(1024), 1)) + for svgdata in svg: + file.write(svgdata) + + file.write('

Bill of Materials

') + listy = flatten2d(bom_list) + file.write('') + file.write('') + for item in listy[0]: + file.write(f'') + file.write('') + for row in listy[1:]: + file.write('') + for i, item in enumerate(row): + item_str = item.replace('\u00b2', '²') + align = 'text-align:right; ' if listy[0][i] == 'Qty' else '' + file.write(f'') + file.write('') + file.write('
{item}
{item_str}
') + + file.write('') From dec64abaf50e3e6966c25c8838109234f92ed512 Mon Sep 17 00:00:00 2001 From: William Sutton Date: Fri, 28 Aug 2020 18:00:21 -0400 Subject: [PATCH 11/38] Add support for wire length units Based on #161, #162, #171. Co-authored-by: stevegt Co-authored-by: kvid --- src/wireviz/DataClasses.py | 4 ++++ src/wireviz/Harness.py | 2 +- src/wireviz/wv_bom.py | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index 48be7062..c2d680d6 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -176,6 +176,7 @@ class Cable: gauge_unit: Optional[str] = None show_equiv: bool = False length: float = 0 + length_unit: Optional[str] = None color: Optional[Color] = None wirecount: Optional[int] = None shield: Union[bool, Color] = False @@ -215,6 +216,9 @@ def __post_init__(self) -> None: self.connections = [] + if self.length_unit is None: #Default wire length units to meters if left undeclared + self.length_unit = 'm' + if self.wirecount: # number of wires explicitly defined if self.colors: # use custom color palette (partly or looped if needed) pass diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index 352f93b2..9d83a3df 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -203,7 +203,7 @@ def create_graph(self) -> Graph: f'{cable.wirecount}x' if cable.show_wirecount else None, f'{cable.gauge} {cable.gauge_unit}{awg_fmt}' if cable.gauge else None, '+ S' if cable.shield else None, - f'{cable.length} m' if cable.length > 0 else None, + f'{cable.length} {cable.length_unit}' if cable.length > 0 else None, cable.color, html_colorbar(cable.color)], '', [html_image(cable.image)], diff --git a/src/wireviz/wv_bom.py b/src/wireviz/wv_bom.py index a854ac86..ac5b071d 100644 --- a/src/wireviz/wv_bom.py +++ b/src/wireviz/wv_bom.py @@ -67,7 +67,7 @@ def generate_bom(harness): + (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, + 'item': description, 'qty': cable.length, 'unit': cable.length_unit, 'designators': cable.name if cable.show_name else None, 'manufacturer': cable.manufacturer, 'mpn': cable.mpn, 'pn': cable.pn }) else: @@ -78,7 +78,7 @@ def generate_bom(harness): + (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, + 'item': description, 'qty': cable.length, 'unit': cable.length_unit, '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) }) From 606ddbf9775da6300299640501083acfaef42077 Mon Sep 17 00:00:00 2001 From: Daniel Rojas Date: Sun, 13 Dec 2020 12:39:29 +0100 Subject: [PATCH 12/38] Detect and assign unit within cable length attribute (#198) Co-authored-by: kvid --- src/wireviz/DataClasses.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index c2d680d6..1607d3bb 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -200,9 +200,11 @@ def __post_init__(self) -> None: try: g, u = self.gauge.split(' ') except Exception: - raise Exception('Gauge must be a number, or number and unit separated by a space') + raise Exception(f'Cable {self.name} gauge={self.gauge} - Gauge must be a number, or number and unit separated by a space') self.gauge = g + if self.gauge_unit is not None: + print(f'Warning: Cable {self.name} gauge_unit={self.gauge_unit} is ignored because its gauge contains {u}') if u.upper() == 'AWG': self.gauge_unit = u.upper() else: @@ -214,11 +216,23 @@ def __post_init__(self) -> None: else: pass # gauge not specified - self.connections = [] - - if self.length_unit is None: #Default wire length units to meters if left undeclared + if isinstance(self.length, str): # length and unit specified + try: + L, u = self.length.split(' ') + L = float(L) + except Exception: + raise Exception(f'Cable {self.name} length={self.length} - Length must be a number, or number and unit separated by a space') + self.length = L + if self.length_unit is not None: + print(f'Warning: Cable {self.name} length_unit={self.length_unit} is ignored because its length contains {u}') + self.length_unit = u + elif not any(isinstance(self.length, t) for t in [int, float]): + raise Exception(f'Cable {self.name} length has a non-numeric value') + elif self.gauge_unit is None: self.length_unit = 'm' + self.connections = [] + if self.wirecount: # number of wires explicitly defined if self.colors: # use custom color palette (partly or looped if needed) pass From da568412908c8daae7e57565ce446e81fd0140a5 Mon Sep 17 00:00:00 2001 From: kvid Date: Tue, 29 Dec 2020 13:31:55 +0100 Subject: [PATCH 13/38] Assign the default cable length unit when not present (#206) Bug: Failing to assign the default cable length unit when not present. It was introduced in #198. Fix: Test the correct cable attribute. This fix solves issue #205. --- src/wireviz/DataClasses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index 1607d3bb..fac6ab58 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -228,7 +228,7 @@ def __post_init__(self) -> None: self.length_unit = u elif not any(isinstance(self.length, t) for t in [int, float]): raise Exception(f'Cable {self.name} length has a non-numeric value') - elif self.gauge_unit is None: + elif self.length_unit is None: self.length_unit = 'm' self.connections = [] From c0a885a80092b589adb56f0367bf5d2a5aa30735 Mon Sep 17 00:00:00 2001 From: gopiballava Date: Tue, 9 Mar 2021 01:04:29 -0500 Subject: [PATCH 14/38] Correctly check for default of None If `show_wirenumbers` is omitted from a cable section, its value will be `None`. In that case, we want to choose a default based on whether this is a bundle or not. If the user did specify `show_wirenumbers`, then its value will be `True` or `False`, and we want to respect that whether it's a bundle or not. --- src/wireviz/DataClasses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index fac6ab58..f3bb3430 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -269,7 +269,7 @@ def __post_init__(self) -> None: raise Exception('lists of part data are only supported for bundles') # by default, show wire numbers for cables, hide for bundles - if not self.show_wirenumbers: + if self.show_wirenumbers is None: self.show_wirenumbers = self.category != 'bundle' for i, item in enumerate(self.additional_components): From 7546991b04c0089f5765dc2854081d6aa90842eb Mon Sep 17 00:00:00 2001 From: KV Date: Fri, 12 Feb 2021 21:31:52 +0100 Subject: [PATCH 15/38] Show connector pins even when all are unconnected Bug: Hiding connector pins when none are connected is not reasonable. When combined with loops or neither pinlabels nor pincolors, then exceptions are raised as well. Fix: Forcing pins at the left side in such cases solves #217. --- src/wireviz/Harness.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index 9d83a3df..5a8c1728 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -111,6 +111,10 @@ def create_graph(self) -> Graph: for connector in self.connectors.values(): + # If no wires connected (except maybe loop wires)? + if not (connector.ports_left or connector.ports_right): + connector.ports_left = True # Use left side pins. + html = [] rows = [[remove_links(connector.name) if connector.show_name else None], From 71a1c52e226b4c7daa1d50ccaab97d4350c97f99 Mon Sep 17 00:00:00 2001 From: Daniel Rojas Date: Sat, 20 Mar 2021 14:20:10 +0100 Subject: [PATCH 16/38] Remove `.` from requirements.txt Closes #172. --- requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 92620aea..36f048c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ -. graphviz pillow pyyaml -setuptools \ No newline at end of file +setuptools From cbc30b7323139fbd09ec1d5f0d77eed956ab1a04 Mon Sep 17 00:00:00 2001 From: Daniel Rojas Date: Tue, 23 Mar 2021 11:53:02 +0100 Subject: [PATCH 17/38] Update changelog and syntax documentation --- docs/CHANGELOG.md | 19 +++++++++++++++++++ docs/syntax.md | 15 +++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9f043a28..2231db45 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,23 @@ # Change Log +## [0.3](https://github.com/formatc1702/WireViz/tree/v0.3) (202X-XX-XX) + +### New features + +- Allow referencing a cable's/bundle's wires by color or by label ([#70](https://github.com/formatc1702/WireViz/issues/70), [#169](https://github.com/formatc1702/WireViz/issues/169), [#193](https://github.com/formatc1702/WireViz/issues/193), [#194](https://github.com/formatc1702/WireViz/pull/194)) +- Allow additional BOM items within components ([#50](https://github.com/formatc1702/WireViz/issues/50), [#115](https://github.com/formatc1702/WireViz/pull/115)) +- Add support for length units in cables and wires ([#7](https://github.com/formatc1702/WireViz/issues/7), [#196](https://github.com/formatc1702/WireViz/pull/196) (with work from [#161](https://github.com/formatc1702/WireViz/pull/161), [#162](https://github.com/formatc1702/WireViz/pull/162), [#171](https://github.com/formatc1702/WireViz/pull/171)), [#198](https://github.com/formatc1702/WireViz/pull/198), [#205](https://github.com/formatc1702/WireViz/issues/205). [#206](https://github.com/formatc1702/WireViz/pull/206)) +- Add option to define connector pin colors ([#53](https://github.com/formatc1702/WireViz/issues/53), [#141](https://github.com/formatc1702/WireViz/pull/141)) +- Remove HTML links from the input attributes ([#164](https://github.com/formatc1702/WireViz/pull/164)) + + +## Misc. fixes + +- Improve type hinting ([#156](https://github.com/formatc1702/WireViz/issues/156), [#163](https://github.com/formatc1702/WireViz/pull/163)) +- Move BOM management and HTML functions to separate modules ([#151](https://github.com/formatc1702/WireViz/issues/151), [#192](https://github.com/formatc1702/WireViz/pull/192)) +- Bug fixes ([#218](https://github.com/formatc1702/WireViz/pull/218), [#221](https://github.com/formatc1702/WireViz/pull/221)) + + ## [0.2](https://github.com/formatc1702/WireViz/tree/v0.2) (2020-10-17) ### Backward incompatible changes @@ -16,6 +34,7 @@ See the [syntax description](syntax.md) for details. ### New features + - Add bidirectional AWG/mm2 conversion ([#40](https://github.com/formatc1702/WireViz/issues/40), [#41](https://github.com/formatc1702/WireViz/pull/41)) - Add support for part numbers ([#11](https://github.com/formatc1702/WireViz/pull/11), [#114](https://github.com/formatc1702/WireViz/issues/114), [#121](https://github.com/formatc1702/WireViz/pull/121)) - Add support for multicolored wires ([#12](https://github.com/formatc1702/WireViz/issues/12), [#17](https://github.com/formatc1702/WireViz/pull/17), [#96](https://github.com/formatc1702/WireViz/pull/96), [#131](https://github.com/formatc1702/WireViz/issues/131), [#132](https://github.com/formatc1702/WireViz/pull/132)) diff --git a/docs/syntax.md b/docs/syntax.md index a0d9041b..2d557511 100644 --- a/docs/syntax.md +++ b/docs/syntax.md @@ -56,6 +56,11 @@ additional_bom_items: # custom items to add to BOM pins: # if omitted, is autofilled with [1, 2, ..., pincount] pinlabels: # if omitted, is autofilled with blanks + # pin color marks (optional) + pincolors: # list of colors to be assigned to the respective pins; + # if list length is lower than connector pinout, + # no color marks will be added to remaining pins + # rendering information (all optional) style: