From 36ffa969f4f6f3bd85da50d65f41a9352e50bdaa Mon Sep 17 00:00:00 2001 From: Daniel Rojas Date: Thu, 21 Oct 2021 17:15:02 +0200 Subject: [PATCH] Improve subclassing of components, prepare for BOM refactoring --- src/wireviz/DataClasses.py | 286 +++++++++++++++++++++++----------- src/wireviz/Harness.py | 106 ++++++++++--- src/wireviz/wireviz.py | 10 +- src/wireviz/wv_bom.py | 306 +++++-------------------------------- src/wireviz/wv_gv_html.py | 30 +--- src/wireviz/wv_helper.py | 11 -- 6 files changed, 341 insertions(+), 408 deletions(-) diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index df91e90..848c3bc 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- +from collections import namedtuple from dataclasses import dataclass, field from enum import Enum from itertools import zip_longest from typing import Dict, List, Optional, Tuple, Union +from wireviz.wv_bom import BomHash, BomHashList, PartNumberInfo from wireviz.wv_colors import ( COLOR_CODES, ColorOutputMode, @@ -145,34 +147,6 @@ class Image: self.height = self.width / aspect_ratio(self.src) -@dataclass -class AdditionalComponent: - type: MultilineHypertext - subtype: Optional[MultilineHypertext] = None - manufacturer: Optional[MultilineHypertext] = None - mpn: Optional[MultilineHypertext] = None - supplier: Optional[MultilineHypertext] = None - spn: Optional[MultilineHypertext] = None - pn: Optional[Hypertext] = None - qty: float = 1 - unit: Optional[str] = None - qty_multiplier: Union[ConnectorMultiplier, CableMultiplier, None] = None - bgcolor: SingleColor = None - - def __post_init__(self): - self.bgcolor = SingleColor(self.bgcolor) - - @property - def description(self) -> str: - s = self.type.rstrip() + f", {self.subtype.rstrip()}" if self.subtype else "" - return s - - -@dataclass -class Component: - pass - - @dataclass class PinClass: index: int @@ -220,43 +194,144 @@ class Connection: @dataclass -class Connector(Component): - name: Designator - bgcolor: SingleColor = None - bgcolor_title: SingleColor = None - manufacturer: Optional[MultilineHypertext] = None - mpn: Optional[MultilineHypertext] = None - supplier: Optional[MultilineHypertext] = None - spn: Optional[MultilineHypertext] = None - pn: Optional[Hypertext] = None +class Component: + category: Optional[str] = None # currently only used by cables, to define bundles + type: Union[MultilineHypertext, List[MultilineHypertext]] = None + subtype: Union[MultilineHypertext, List[MultilineHypertext]] = None + + # part number + partnumbers: PartNumberInfo = None # filled by fill_partnumbers() + # the following are provided for user convenience and should not be accessed later. + # their contents are loaded into partnumbers during the child class __post_init__() + pn: str = None + manufacturer: str = None + mpn: str = None + supplier: str = None + spn: str = None + + ignore_in_bom: bool = False + bom_id: Optional[str] = None # to be filled after harness is built + + def fill_partnumbers(self): + self.partnumbers = PartNumberInfo( + self.pn, self.manufacturer, self.mpn, self.supplier, self.spn + ) + + @property + def bom_hash(self) -> BomHash: + def _force_list(inp): + if isinstance(inp, list): + return inp + else: + return [inp for i in range(len(self.colors))] + + if self.category == "bundle": + # create a temporary single item that includes the necessary fields, + # which may or may not be lists + _hash_list = BomHashList( + self.description, + self.unit, + self.partnumbers, + ) + # convert elements that are not lists, into lists + _hash_matrix = list(map(_force_list, [elem for elem in _hash_list])) + # transpose list of lists, convert to tuple for next step + _hash_matrix = list(map(tuple, zip(*_hash_matrix))) + # generate list of BomHashes + hash_list = [BomHash(*item) for item in _hash_matrix] + return hash_list + else: + return BomHash( + self.description, + self.unit, + self.partnumbers, + ) + + +@dataclass +class AdditionalComponent(Component): + qty: float = 1 + unit: Optional[str] = None + qty_multiplier: Union[ConnectorMultiplier, CableMultiplier, None] = None + designators: Optional[str] = None # used for components definedi in the + # additional_bom_items section within another component + bgcolor: SingleColor = None # ^ same here + + def __post_init__(self): + super().fill_partnumbers() + self.bgcolor = SingleColor(self.bgcolor) + + @property + def description(self) -> str: + s = self.type.rstrip() + f", {self.subtype.rstrip()}" if self.subtype else "" + return s + + +@dataclass +class GraphicalComponent(Component): # abstract class, for future use + bgcolor: Optional[SingleColor] = None + + +@dataclass +class TopLevelGraphicalComponent(GraphicalComponent): # abstract class + # component properties + designator: Designator = None + color: Optional[SingleColor] = None + image: Optional[Image] = None + additional_components: List[AdditionalComponent] = field(default_factory=list) + notes: Optional[MultilineHypertext] = None + # rendering options + bgcolor_title: Optional[SingleColor] = None + show_name: Optional[bool] = None + + +@dataclass +class Connector(TopLevelGraphicalComponent): + # connector-specific properties style: Optional[str] = None category: Optional[str] = None - type: Optional[MultilineHypertext] = None - subtype: Optional[MultilineHypertext] = None + loops: List[List[Pin]] = field(default_factory=list) + # pin information in particular pincount: Optional[int] = None - image: Optional[Image] = None - notes: Optional[MultilineHypertext] = None - pins: List[Pin] = field(default_factory=list) - pinlabels: List[Pin] = field(default_factory=list) - pincolors: List[str] = field(default_factory=list) - color: MultiColor = None - show_name: Optional[bool] = None + pins: List[Pin] = field(default_factory=list) # legacy + pinlabels: List[Pin] = field(default_factory=list) # legacy + pincolors: List[str] = field(default_factory=list) # legacy + pin_objects: List[PinClass] = field( + default_factory=list + ) # new, to replace the lists above + # rendering option show_pincount: Optional[bool] = None hide_disconnected_pins: bool = False - loops: List[List[Pin]] = field(default_factory=list) - ignore_in_bom: bool = False - additional_components: List[AdditionalComponent] = field(default_factory=list) - pin_objects: List[PinClass] = field(default_factory=list) @property def is_autogenerated(self): - return self.name.startswith(AUTOGENERATED_PREFIX) + import pudb + + pudb.set_trace() + return self.designator.startswith(AUTOGENERATED_PREFIX) + + @property + def description(self) -> str: + substrs = [ + "Connector", + self.type, + self.subtype, + self.pincount if self.show_pincount else None, + str(self.color) if self.color else None, + ] + return ", ".join([str(s) for s in substrs if s is not None and s != ""]) def should_show_pin(self, pin_name): return not self.hide_disconnected_pins or self.visible_pins.get(pin_name, False) + @property + def unit(self): # for compatibility with BOM hashing + return None # connectors do not support units. + def __post_init__(self) -> None: + super().fill_partnumbers() + self.bgcolor = SingleColor(self.bgcolor) self.bgcolor_title = SingleColor(self.bgcolor_title) self.color = SingleColor(self.color) @@ -305,7 +380,7 @@ class Connector(Component): id=pin_id, label=pin_label, color=MultiColor(pin_color), - parent=self.name, + parent=self.designator, _anonymous=self.is_autogenerated, _simple=self.style == "simple", ) @@ -337,9 +412,9 @@ class Connector(Component): def _check_if_unique_id(self, id): results = [pin for pin in self.pin_objects if pin.id == id] if len(results) == 0: - raise Exception(f"Pin ID {id} not found in {self.name}") + raise Exception(f"Pin ID {id} not found in {self.designator}") if len(results) > 1: - raise Exception(f"Pin ID {id} found more than once in {self.name}") + raise Exception(f"Pin ID {id} found more than once in {self.designator}") return True def get_pin_by_id(self, id): @@ -368,41 +443,36 @@ class Connector(Component): @dataclass -class Cable(Component): - name: Designator - bgcolor: SingleColor = None - bgcolor_title: SingleColor = None - manufacturer: Union[MultilineHypertext, List[MultilineHypertext], None] = None - mpn: Union[MultilineHypertext, List[MultilineHypertext], None] = None - supplier: Union[MultilineHypertext, List[MultilineHypertext], None] = None - spn: Union[MultilineHypertext, List[MultilineHypertext], None] = None - pn: Union[Hypertext, List[Hypertext], None] = None - category: Optional[str] = None - type: Optional[MultilineHypertext] = None +class Cable(TopLevelGraphicalComponent): + # cable-specific properties gauge: Optional[float] = None gauge_unit: Optional[str] = None - show_equiv: bool = False length: float = 0 length_unit: Optional[str] = None - color: MultiColor = None + color_code: Optional[str] = None + # wire information in particular wirecount: Optional[int] = None shield: Union[bool, MultiColor] = False - image: Optional[Image] = None - notes: Optional[MultilineHypertext] = None - colors: List[str] = field(default_factory=list) - wirelabels: List[Wire] = field(default_factory=list) - color_code: Optional[str] = None + colors: List[str] = field(default_factory=list) # legacy + wirelabels: List[Wire] = field(default_factory=list) # legacy + wire_objects: List[WireClass] = field( + default_factory=list + ) # new, to replace the lists above + # internal + _connections: List[Connection] = field(default_factory=list) + # rendering options show_name: Optional[bool] = None + show_equiv: bool = False show_wirecount: bool = True show_wirenumbers: Optional[bool] = None - ignore_in_bom: bool = False - additional_components: List[AdditionalComponent] = field(default_factory=list) - connections: List[Connection] = field(default_factory=list) - wire_objects: List[WireClass] = field(default_factory=list) @property def is_autogenerated(self): - return self.name.startswith(AUTOGENERATED_PREFIX) + return self.designator.startswith(AUTOGENERATED_PREFIX) + + @property + def unit(self): # for compatibility with parent class + return self.length_unit @property def gauge_str(self): @@ -420,8 +490,43 @@ class Cable(Component): equivalent_gauge = f" ({mm2_equiv(self.gauge)} mm\u00B2)" return f"{actual_gauge}{equivalent_gauge}" + @property + def description(self) -> str: + if self.category == "bundle": + desc_list = [] + for index, color in enumerate(self.colors): + substrs = [ + "Wire", + self.type, + self.subtype, + f"{self.gauge} {self.gauge_unit}" if self.gauge else None, + str(self.color) + if self.color + else None, # translate_color(self.color, harness.options.color_mode)] <- get harness.color_mode! + ] + desc_list.append( + ", ".join([s for s in substrs if s is not None and s != ""]) + ) + return desc_list + else: + substrs = [ + ("", "Cable"), + (", ", self.type), + (", ", self.subtype), + (", ", self.wirecount), + (" ", f"x {self.gauge} {self.gauge_unit}" if self.gauge else " wires"), + (" ", "shielded" if self.shield else None), + (", ", str(self.color) if self.color else None), + ] + desc = "".join( + [f"{s[0]}{s[1]}" for s in substrs if s[1] is not None and s[1] != ""] + ) + return desc + def __post_init__(self) -> None: + super().fill_partnumbers() + self.bgcolor = SingleColor(self.bgcolor) self.bgcolor_title = SingleColor(self.bgcolor_title) self.color = SingleColor(self.color) @@ -434,14 +539,14 @@ class Cable(Component): g, u = self.gauge.split(" ") except Exception: raise Exception( - f"Cable {self.name} gauge={self.gauge} - " + f"Cable {self.designator} 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} " + f"Warning: Cable {self.designator} gauge_unit={self.gauge_unit} " f"is ignored because its gauge contains {u}" ) if u.upper() == "AWG": @@ -461,18 +566,18 @@ class Cable(Component): L = float(L) except Exception: raise Exception( - f"Cable {self.name} length={self.length} - " + f"Cable {self.designator} 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 " + f"Warning: Cable {self.designator} length_unit={self.length_unit} is ignored " f"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") + raise Exception(f"Cable {self.designator} length has a non-numeric value") elif self.length_unit is None: self.length_unit = "m" @@ -530,7 +635,7 @@ class Cable(Component): id=wire_index + 1, # TODO: wire_id label=wire_label, color=MultiColor(wire_color), - parent=self.name, + parent=self.designator, ) ) @@ -545,7 +650,7 @@ class Cable(Component): color=MultiColor(self.shield) if isinstance(self.shield, str) else MultiColor(None), - parent=self.name, + parent=self.designator, ) ) @@ -563,16 +668,19 @@ class Cable(Component): def get_wire_by_id(self, id): wire = [wire for wire in self.wire_objects if wire.id == id] if len(wire) == 0: - raise Exception(f"Wire ID {id} not found in {self.name}") + raise Exception(f"Wire ID {id} not found in {self.designator}") if len(wire) > 1: - raise Exception(f"Wire ID {id} found more than once in {self.name}") + raise Exception(f"Wire ID {id} found more than once in {self.designator}") return wire[0] - def connect( - self, from_pin_obj: [PinClass], via_wire_id: str, to_pin_obj: [PinClass] + def _connect( + self, + from_pin_obj: [PinClass], + via_wire_id: str, + to_pin_obj: [PinClass], ) -> None: via_wire_obj = self.get_wire_by_id(via_wire_id) - self.connections.append(Connection(from_pin_obj, via_wire_obj, to_pin_obj)) + self._connections.append(Connection(from_pin_obj, via_wire_obj, to_pin_obj)) 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 9d46fbc..abde2e9 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -1,12 +1,15 @@ # -*- coding: utf-8 -*- -from dataclasses import dataclass +from collections import defaultdict +from dataclasses import dataclass, field from pathlib import Path +from typing import List from graphviz import Graph import wireviz.wv_colors from wireviz.DataClasses import ( + AdditionalComponent, Arrow, ArrowWeight, Cable, @@ -19,7 +22,6 @@ from wireviz.DataClasses import ( Tweak, ) from wireviz.svgembed import embed_svg_images_file -from wireviz.wv_bom import bom_list, generate_bom from wireviz.wv_gv_html import ( apply_dot_tweaks, calculate_node_bgcolor, @@ -39,19 +41,29 @@ class Harness: metadata: Metadata options: Options tweak: Tweak + additional_bom_items: List[AdditionalComponent] = field(default_factory=list) def __post_init__(self): self.connectors = {} self.cables = {} self.mates = [] - self._bom = [] # Internal Cache for generated bom + self._bom = defaultdict(dict) self.additional_bom_items = [] - def add_connector(self, name: str, *args, **kwargs) -> None: - self.connectors[name] = Connector(name, *args, **kwargs) + def add_connector(self, designator: str, *args, **kwargs) -> None: + conn = Connector(designator=designator, *args, **kwargs) + self.connectors[designator] = conn + self._add_to_internal_bom(conn) - def add_cable(self, name: str, *args, **kwargs) -> None: - self.cables[name] = Cable(name, *args, **kwargs) + def add_cable(self, designator: str, *args, **kwargs) -> None: + cbl = Cable(designator=designator, *args, **kwargs) + self.cables[designator] = cbl + self._add_to_internal_bom(cbl) + + def add_additional_bom_item(self, item: dict) -> None: + new_item = AdditionalComponent(**item) + self.additional_bom_items.append(new_item) + self._add_to_internal_bom(new_item) def add_mate_pin(self, from_name, from_pin, to_name, to_pin, arrow_str) -> None: from_con = self.connectors[from_name] @@ -68,8 +80,65 @@ class Harness: arrow = Arrow(direction=parse_arrow_str(arrow_str), weight=ArrowWeight.SINGLE) self.mates.append(MateComponent(from_name, to_name, arrow)) - def add_bom_item(self, item: dict) -> None: - self.additional_bom_items.append(item) + def _add_to_internal_bom(self, item): + if item.ignore_in_bom: + return + + def _add(hash, designator=None, qty=1, category=None): + # generate entry + bom_entry = self._bom[hash] + # initialize missing fields + if not "qty" in bom_entry: + bom_entry["qty"] = 0 + if not "designators" in bom_entry: + bom_entry["designators"] = set() + # update fields + bom_entry["qty"] += qty + if designator: + if isinstance(designator, str): + bom_entry["designators"].add(designator) + else: + bom_entry["designators"].update(designator) + bom_entry["category"] = category + + if isinstance(item, Connector): + _add(item.bom_hash, designator=item.designator, category="connector") + for comp in item.additional_components: + if comp.ignore_in_bom: + continue + _add( + comp.bom_hash, + designator=item.designator, + qty=comp.qty, + category="connector/additional", + ) + elif isinstance(item, Cable): + _bom_hash = item.bom_hash + if isinstance(_bom_hash, list): + _cat = "bundle" + for subhash in _bom_hash: + _add(subhash, designator=item.designator, category=_cat) + else: + _cat = "cable" + _add(item.bom_hash, designator=item.designator, category=_cat) + for comp in item.additional_components: + if comp.ignore_in_bom: + continue + _add( + comp.bom_hash, + designator=item.designator, + qty=comp.qty, + category=f"{_cat}/additional", + ) + elif isinstance(item, AdditionalComponent): # additional component + _add( + item.bom_hash, + designator=item.designators, + qty=item.qty, + category="additional", + ) + else: + raise Exception(f"Unknown type of item:\n{item}") def connect( self, @@ -145,7 +214,7 @@ class Harness: else: to_pin_obj = None - self.cables[via_name].connect(from_pin_obj, via_wire, to_pin_obj) + self.cables[via_name]._connect(from_pin_obj, via_wire, to_pin_obj) if from_name in self.connectors: self.connectors[from_name].activate_pin(from_pin, Side.RIGHT) if to_name in self.connectors: @@ -160,7 +229,7 @@ class Harness: gv_html = gv_node_component(connector) bgcolor = calculate_node_bgcolor(connector, self.options) dot.node( - connector.name, + connector.designator, label=f"<\n{gv_html}\n>", bgcolor=bgcolor, shape="box", @@ -192,7 +261,7 @@ class Harness: bgcolor = calculate_node_bgcolor(cable, self.options) style = "filled,dashed" if cable.category == "bundle" else "filled" dot.node( - cable.name, + cable.designator, label=f"<\n{gv_html}\n>", bgcolor=bgcolor, shape="box", @@ -200,7 +269,7 @@ class Harness: ) # generate wire edges between component nodes and cable nodes - for connection in cable.connections: + for connection in cable._connections: color, l1, l2, r1, r2 = gv_edge_wire(self, cable, connection) dot.attr("edge", color=color) if not (l1, l2) == (None, None): @@ -268,7 +337,8 @@ class Harness: if "gv" in fmt: graph.save(filename=f"{filename}.gv") # BOM output - bomlist = bom_list(self.bom()) + # bomlist = bom_list(self.bom()) + bomlist = [[]] if "tsv" in fmt: open_file_write(f"{filename}.bom.tsv").write(tuplelist2tsv(bomlist)) if "csv" in fmt: @@ -288,7 +358,7 @@ class Harness: elif "svg" in fmt: Path(f"{filename}.tmp.svg").replace(f"{filename}.svg") - def bom(self): - if not self._bom: - self._bom = generate_bom(self) - return self._bom + # def bom(self): + # if not self._bom: + # self._bom = generate_bom(self) + # return self._bom diff --git a/src/wireviz/wireviz.py b/src/wireviz/wireviz.py index 9754886..4478f99 100755 --- a/src/wireviz/wireviz.py +++ b/src/wireviz/wireviz.py @@ -168,7 +168,7 @@ def parse( ) designator = ( f"{AUTOGENERATED_PREFIX}" - "{template}_{autogenerated_designators[template]}" + f"{template}_{autogenerated_designators[template]}" ) # check if redefining existing component to different template if designator in designators_and_templates: @@ -288,7 +288,7 @@ def parse( # generate new connector instance from template check_type(designator, template, "connector") harness.add_connector( - name=designator, **template_connectors[template] + designator=designator, **template_connectors[template] ) elif designator in harness.cables: # existing cable instance @@ -296,7 +296,9 @@ def parse( elif template in template_cables.keys(): # generate new cable instance from template check_type(designator, template, "cable/arrow") - harness.add_cable(name=designator, **template_cables[template]) + harness.add_cable( + designator=designator, **template_cables[template] + ) elif is_arrow(designator): check_type(designator, template, "cable/arrow") @@ -366,7 +368,7 @@ def parse( if "additional_bom_items" in yaml_data: for line in yaml_data["additional_bom_items"]: - harness.add_bom_item(line) + harness.add_additional_bom_item(line) if output_formats: harness.output(filename=output_file, fmt=output_formats, view=False) diff --git a/src/wireviz/wv_bom.py b/src/wireviz/wv_bom.py index 4e075a3..f8a0c3b 100644 --- a/src/wireviz/wv_bom.py +++ b/src/wireviz/wv_bom.py @@ -1,277 +1,55 @@ # -*- coding: utf-8 -*- -from dataclasses import asdict -from itertools import groupby -from typing import Any, Dict, List, Optional, Tuple, Union +from collections import namedtuple +from dataclasses import dataclass +from enum import Enum +from typing import List, Optional, Union -from wireviz.DataClasses import AdditionalComponent, Cable, Connector -from wireviz.wv_gv_html import html_line_breaks -from wireviz.wv_helper import clean_whitespace, pn_info_string +BOM_HASH_FIELDS = "description unit partnumbers" +BomHash = namedtuple("BomHash", BOM_HASH_FIELDS) +BomHashList = namedtuple("BomHashList", BOM_HASH_FIELDS) -BOM_COLUMNS_ALWAYS = ("id", "description", "qty", "unit", "designators") -BOM_COLUMNS_OPTIONAL = ("pn", "manufacturer", "mpn", "supplier", "spn") -BOM_COLUMNS_IN_KEY = ("description", "unit") + BOM_COLUMNS_OPTIONAL +BomCategory = Enum( + "BomEntry", "CONNECTOR CABLE WIRE ADDITIONAL_INSIDE ADDITIONAL_OUTSIDE" +) -HEADER_PN = "P/N" -HEADER_MPN = "MPN" -HEADER_SPN = "SPN" +PartNumberInfo = namedtuple("PartNumberInfo", "pn manufacturer mpn supplier spn") -BOMKey = Tuple[str, ...] -BOMColumn = str # = Literal[*BOM_COLUMNS_ALWAYS, *BOM_COLUMNS_OPTIONAL] -BOMEntry = Dict[BOMColumn, Union[str, int, float, List[str], None]] +PART_NUMBER_HEADERS = PartNumberInfo( + pn="P/N", manufacturer=None, mpn="MPN", supplier=None, spn="SPN" +) -def optional_fields(part: Union[Connector, Cable, AdditionalComponent]) -> BOMEntry: - """Return part field values for the optional BOM columns as a dict.""" - part = asdict(part) - return {field: part.get(field) for field in BOM_COLUMNS_OPTIONAL} +@dataclass +class BomEntry: + hash: BomHash # includes description, part number info, + description: str + qty: Union[int, float] + unit: str + designators: List[str] + _category: BomCategory # for sorting -def get_additional_component_table( - harness: "Harness", component: Union[Connector, Cable] -) -> List[str]: - """Return a list of diagram node table row strings with additional components.""" - rows = [] - if component.additional_components: - rows.append(["Additional components"]) - for part in component.additional_components: - common_args = { - "qty": part.qty * component.get_qty_multiplier(part.qty_multiplier), - "unit": part.unit, - "bgcolor": part.bgcolor, - } - if harness.options.mini_bom_mode: - id = get_bom_index( - harness.bom(), - bom_entry_key({**asdict(part), "description": part.description}), - ) - rows.append( - component_table_entry( - f"#{id} ({part.type.rstrip()})", **common_args - ) - ) - else: - rows.append( - component_table_entry( - part.description, **common_args, **optional_fields(part) - ) - ) - return rows - - -def get_additional_component_bom(component: Union[Connector, Cable]) -> List[BOMEntry]: - """Return a list of BOM entries with additional components.""" - bom_entries = [] - for part in component.additional_components: - bom_entries.append( - { - "description": part.description, - "qty": part.qty * component.get_qty_multiplier(part.qty_multiplier), - "unit": part.unit, - "designators": component.name if component.show_name else None, - **optional_fields(part), - } - ) - return bom_entries - - -def bom_entry_key(entry: BOMEntry) -> BOMKey: - """Return a tuple of string values from the dict that must be equal to join BOM entries.""" - if "key" not in entry: - entry["key"] = tuple( - clean_whitespace(make_str(entry.get(c))) for c in BOM_COLUMNS_IN_KEY - ) - return entry["key"] - - -def generate_bom(harness: "Harness") -> List[BOMEntry]: - """Return a list of BOM entries generated from the 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", xxx" # {translate_color(connector.color, harness.options.color_mode)} - if connector.color - else "" - ) - ) - bom_entries.append( - { - "description": description, - "designators": connector.name if connector.show_name else None, - **optional_fields(connector), - } - ) - - # 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 description? - 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 "") - + ( - f", xxx" # {translate_color(cable.color, harness.options.color_mode)} - if cable.color - else "" - ) - ) - bom_entries.append( - { - "description": description, - "qty": cable.length, - "unit": cable.length_unit, - "designators": cable.name if cable.show_name else None, - **optional_fields(cable), - } - ) - 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", xxx" # {translate_color(color, harness.options.color_mode)} - if color - else "" - ) - ) - bom_entries.append( - { - "description": description, - "qty": cable.length, - "unit": cable.length_unit, - "designators": cable.name if cable.show_name else None, - **{ - k: index_if_list(v, index) - for k, v in optional_fields(cable).items() - }, - } - ) - - # add cable/bundles aditional components to bom - bom_entries.extend(get_additional_component_bom(cable)) - - # add harness aditional components to bom directly, as they both are List[BOMEntry] - bom_entries.extend(harness.additional_bom_items) - - # 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 +def partnumbers_to_list(partnumbers: PartNumberInfo) -> List[str]: + cell_contents = [ + pn_info_string(PART_NUMBER_HEADERS.pn, None, partnumbers.pn), + pn_info_string( + PART_NUMBER_HEADERS.mpn, partnumbers.manufacturer, partnumbers.mpn + ), + pn_info_string(PART_NUMBER_HEADERS.spn, partnumbers.supplier, partnumbers.spn), ] - - # deduplicate bom - bom = [] - for _, group in groupby(sorted(bom_entries, key=bom_entry_key), key=bom_entry_key): - group_entries = list(group) - designators = sum( - (make_list(entry.get("designators")) for entry in group_entries), [] - ) - total_qty = sum(entry.get("qty", 1) for entry in group_entries) - bom.append( - { - **group_entries[0], - "qty": round(total_qty, 3), - "designators": sorted(set(designators)), - } - ) - - # add an incrementing id to each bom entry - return [{**entry, "id": index} for index, entry in enumerate(bom, 1)] + if any(cell_contents): + return [html_line_breaks(cell) for cell in cell_contents] + else: + return None -def get_bom_index(bom: List[BOMEntry], target: BOMKey) -> int: - """Return id of BOM entry or raise exception if not found.""" - for entry in bom: - if bom_entry_key(entry) == target: - return entry["id"] - raise Exception("Internal error: No BOM entry found matching: " + "|".join(target)) - - -def bom_list(bom: List[BOMEntry]) -> List[List[str]]: - """Return list of BOM rows as lists of column strings with headings in top row.""" - keys = list(BOM_COLUMNS_ALWAYS) # Always include this fixed set of BOM columns. - for fieldname in BOM_COLUMNS_OPTIONAL: - # Include only those optional BOM columns that are in use. - if any(entry.get(fieldname) for entry in bom): - keys.append(fieldname) - # Custom mapping from internal name to BOM column headers. - # Headers not specified here are generated by capitilising the internal name. - bom_headings = { - "pn": HEADER_PN, - "mpn": HEADER_MPN, - "spn": HEADER_SPN, - } - return [ - [bom_headings.get(k, k.capitalize()) for k in keys] - ] + [ # Create header row with key names - [make_str(entry.get(k)) for k in keys] for entry in bom - ] # Create string list for each entry row - - -def component_table_entry( - type: str, - qty: Union[int, float], - unit: Optional[str] = None, - bgcolor: Optional[str] = None, - pn: Optional[str] = None, - manufacturer: Optional[str] = None, - mpn: Optional[str] = None, - supplier: Optional[str] = None, - spn: Optional[str] = None, -) -> str: - """Return a diagram node table row string with an additional component.""" - part_number_list = [ - pn_info_string(HEADER_PN, None, pn), - pn_info_string(HEADER_MPN, manufacturer, mpn), - pn_info_string(HEADER_SPN, supplier, spn), - ] - output = ( - f"{qty}" - + (f" {unit}" if unit else "") - + f" x {type}" - + ("
" if any(part_number_list) else "") - + (", ".join([pn for pn in part_number_list if pn])) - ) - # 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""" - -
{html_line_breaks(output)}
""" - - -def index_if_list(value: Any, index: int) -> Any: - """Return the value indexed if it is a list, or simply the value otherwise.""" - return value[index] if isinstance(value, list) else value - - -def make_list(value: Any) -> list: - """Return value if a list, empty list if None, or single element list otherwise.""" - return value if isinstance(value, list) else [] if value is None else [value] - - -def make_str(value: Any) -> str: - """Return comma separated elements if a list, empty string if None, or value as a string otherwise.""" - return ", ".join(str(element) for element in make_list(value)) +def pn_info_string( + header: str, name: Optional[str], number: Optional[str] +) -> Optional[str]: + """Return the company name and/or the part number in one single string or None otherwise.""" + number = str(number).strip() if number is not None else "" + if name or number: + return f'{name if name else header}{": " + number if number else ""}' + else: + return None diff --git a/src/wireviz/wv_gv_html.py b/src/wireviz/wv_gv_html.py index 3c0f693..5c6bee6 100644 --- a/src/wireviz/wv_gv_html.py +++ b/src/wireviz/wv_gv_html.py @@ -14,16 +14,14 @@ from wireviz.DataClasses import ( MateComponent, MatePin, Options, + PartNumberInfo, ShieldClass, WireClass, ) -from wireviz.wv_helper import pn_info_string, remove_links +from wireviz.wv_bom import partnumbers_to_list +from wireviz.wv_helper import remove_links from wireviz.wv_table_util import * # TODO: explicitly import each needed tag later -HEADER_PN = "P/N" -HEADER_MPN = "MPN" -HEADER_SPN = "SPN" - def gv_node_component(component: Component) -> Table: # If no wires connected (except maybe loop wires)? @@ -33,12 +31,12 @@ def gv_node_component(component: Component) -> Table: # generate all rows to be shown in the node if component.show_name: - str_name = f"{remove_links(component.name)}" + str_name = f"{remove_links(component.designator)}" line_name = colored_cell(str_name, component.bgcolor_title) else: line_name = None - line_pn = part_number_str_list(component) + line_pn = partnumbers_to_list(component.partnumbers) is_simple_connector = ( isinstance(component, Connector) and component.style == "simple" @@ -204,8 +202,8 @@ def gv_connector_loops(connector: Connector) -> List: else: raise Exception("No side for loops") for loop in connector.loops: - head = f"{connector.name}:p{loop[0]}{loop_side}:{loop_dir}" - tail = f"{connector.name}:p{loop[1]}{loop_side}:{loop_dir}" + head = f"{connector.designator}:p{loop[0]}{loop_side}:{loop_dir}" + tail = f"{connector.designator}:p{loop[1]}{loop_side}:{loop_dir}" loop_edges.append((head, tail)) return loop_edges @@ -230,7 +228,7 @@ def gv_conductor_table(cable) -> Table: wireinfo.append(wire.label) ins, outs = [], [] - for conn in cable.connections: + for conn in cable._connections: if conn.via.id == wire.id: if conn.from_ is not None: ins.append(str(conn.from_)) @@ -407,18 +405,6 @@ def colored_cell(contents, bgcolor) -> Td: return Td(contents, bgcolor=bgcolor.html) -def part_number_str_list(component: Component) -> List[str]: - cell_contents = [ - pn_info_string(HEADER_PN, None, component.pn), - pn_info_string(HEADER_MPN, component.manufacturer, component.mpn), - pn_info_string(HEADER_SPN, component.supplier, component.spn), - ] - if any(cell_contents): - return [html_line_breaks(cell) for cell in cell_contents] - else: - return None - - def colorbar_cell(color) -> Td: return Td("", bgcolor=color.html, width=4) diff --git a/src/wireviz/wv_helper.py b/src/wireviz/wv_helper.py index fdc1e7a..f86dfa3 100644 --- a/src/wireviz/wv_helper.py +++ b/src/wireviz/wv_helper.py @@ -176,14 +176,3 @@ def smart_file_resolve(filename: str, possible_paths: (str, List[str])) -> Path: f"{filename} was not found in any of the following locations: \n" + "\n".join([str(x) for x in possible_paths]) ) - - -def pn_info_string( - header: str, name: Optional[str], number: Optional[str] -) -> Optional[str]: - """Return the company name and/or the part number in one single string or None otherwise.""" - number = str(number).strip() if number is not None else "" - if name or number: - return f'{name if name else header}{": " + number if number else ""}' - else: - return None