From b34db3d79067e692815000652077afe1d8c3940a Mon Sep 17 00:00:00 2001 From: Laurier Loiselle Date: Mon, 30 Jan 2023 16:12:03 -0500 Subject: [PATCH] wv_dataclasses: dataclasses for BomItem and PartNumberInfo --- src/wireviz/wv_dataclasses.py | 850 ++++++++++++++++++++++------------ 1 file changed, 562 insertions(+), 288 deletions(-) diff --git a/src/wireviz/wv_dataclasses.py b/src/wireviz/wv_dataclasses.py index bd2975c..fcae863 100644 --- a/src/wireviz/wv_dataclasses.py +++ b/src/wireviz/wv_dataclasses.py @@ -1,18 +1,11 @@ # -*- coding: utf-8 -*- - import logging -from collections import namedtuple from dataclasses import dataclass, field -from enum import Enum +from enum import Enum, IntEnum from itertools import zip_longest +from math import modf from typing import Any, Dict, List, Optional, Tuple, Union -from wireviz.wv_bom import ( - BomHash, - PartNumberInfo, - QtyMultiplierCable, - QtyMultiplierConnector, -) from wireviz.wv_colors import ( COLOR_CODES, ColorOutputMode, @@ -54,10 +47,20 @@ MetadataKeys = PlainText # Literal['title', 'description', 'notes', ...] Side = Enum("Side", "LEFT RIGHT") ArrowDirection = Enum("ArrowDirection", "NONE BACK FORWARD BOTH") ArrowWeight = Enum("ArrowWeight", "SINGLE DOUBLE") -NumberAndUnit = namedtuple("NumberAndUnit", "number unit") +# NumberAndUnit = namedtuple("NumberAndUnit", "number unit") AUTOGENERATED_PREFIX = "AUTOGENERATED_" +BomCategory = IntEnum( # to enforce ordering in BOM + "BomEntry", "CONNECTOR CABLE WIRE PIN ADDITIONAL BUNDLE" +) +QtyMultiplierConnector = Enum( + "QtyMultiplierConnector", "PINCOUNT POPULATED CONNECTIONS" +) +QtyMultiplierCable = Enum( + "QtyMultiplierCable", "WIRECOUNT TERMINATION LENGTH TOTAL_LENGTH" +) + @dataclass class Arrow: @@ -65,10 +68,323 @@ class Arrow: weight: ArrowWeight +# TODO: standardize metadata to indicate which are supported... class Metadata(dict): pass +@dataclass +class NumberAndUnit: + number: float + unit: Optional[str] = None + + @staticmethod + def to_number_and_unit( + inp: Any, + default_unit: Optional[str] = None, + default_value: Optional[float] = None, + ): + if inp is None: + if default_value is not None: + return NumberAndUnit(default_value, default_unit) + return None + elif isinstance(inp, NumberAndUnit): + return inp + elif isinstance(inp, float) or isinstance(inp, int): + return NumberAndUnit(float(inp), default_unit) + elif isinstance(inp, str): + if " " in inp: + number, unit = inp.split(" ", 1) + else: + number, unit = inp, default_unit + try: + number = float(number) + except ValueError: + raise Exception( + f"{inp} is not a valid number and unit.\n" + "It must be a number, or a number and unit separated by a space." + ) + else: + return NumberAndUnit(number, unit) + + def chose_unit(self, other): + if self.unit is None: + return other.unit + + if other.unit is not None and self.unit != other.unit: + raise ValueError(f"Cannot add {self} and {other}, units not matching") + return self.unit + + @property + def number_str(self): + return f"{self.number:.2f}" if modf(self.number)[0] else f"{int(self.number)}" + + @property + def unit_str(self): + return "" if self.unit is None else self.unit + + def __str__(self): + return " ".join((self.number_str, self.unit_str)).strip() + + def __eq__(self, other): + return self.number == other.number and self.unit == other.unit + + def __add__(self, other): + other = NumberAndUnit.to_number_and_unit(other, self.unit, 0) + + return NumberAndUnit( + number=float(self.number) + float(other.number), + unit=self.chose_unit(other), + ) + + def __mul__(self, other): + other = NumberAndUnit.to_number_and_unit(other, self.unit, 1) + + return NumberAndUnit( + number=float(self.number) * float(other.number), + unit=self.chose_unit(other), + ) + + +@dataclass +class PartNumberInfo: + pn: Optional[str] = None + manufacturer: Optional[str] = None + mpn: Optional[str] = None + supplier: Optional[str] = None + spn: Optional[str] = None + + BOM_KEY_TO_COLUMNS = { + "pn": "P/N", + "manufacturer": "Manufacturer", + "mpn": "MPN", + "supplier": "Supplier", + "spn": "SPN", + } + + def __hash__(self): + return hash((self.pn, self.manufacturer, self.mpn, self.supplier, self.spn)) + + def __eq__(self, other): + return hash(self) == hash(other) + + def __getitem__(self, key): + return getattr(self, key) + + def __setitem__(self, key, value): + setattr(self, key, value) + + def __post_init__(self): + empty_if_none = lambda x: "" if x is None else str(x) + + if isinstance(self.pn, list): + import pdb + + pdb.set_trace() + self.pn = empty_if_none(self.pn) + self.manufacturer = empty_if_none(self.manufacturer) + self.mpn = empty_if_none(self.mpn) + self.supplier = empty_if_none(self.supplier) + self.spn = empty_if_none(self.spn) + + @property + def bom_keys(self): + return list(self.BOM_KEY_TO_COLUMNS.keys()) + + @property + def bom_dict(self): + return {k: self[k] for k in self.bom_keys} + + @property + def str_list(self): + l = ["", "", ""] + if self.pn: + l[0] = f"P/N: {self.pn}" + l[1] = self.manufacturer + if self.mpn: + if not l[1]: + l[1] = "MPN" + l[1] += ": " + l[1] += self.mpn + l[2] = self.supplier + if self.spn: + if not l[2]: + l[2] = "SPN" + l[2] += ": " + l[2] += self.spn + return [i for i in l if i] + + def copy(self): + return PartNumberInfo( + pn=self.pn, + manufacturer=self.manufacturer, + mpn=self.mpn, + supplier=self.supplier, + spn=self.spn, + ) + + def keep_only_eq(self, other): + part = self.copy() + + if other is None: + return None + + if isinstance(other, list): + for item in other: + part = part.keep_only_eq(item) + else: + for k in ["pn", "manufacturer", "mpn", "supplier", "spn"]: + if part[k] != other[k]: + part[k] = "" + return part + + +@dataclass +class BomEntry: + qty: NumberAndUnit + partnumbers: PartNumberInfo + id: str + amount: Optional[NumberAndUnit] = None + qty_multiplier: Union[int, float] = 1 + description: Optional[str] = None + category: Optional[str] = None + ignore_in_bom: Optional[bool] = False + + # Used to add all occurence of a BomEntry + designators: [List] = field(default_factory=list) + per_harness: [Dict] = field(default_factory=dict) + + # Used to restrict printed lengths + MAX_PRINTED_DESCRIPTION: int = 40 + MAX_PRINTED_DESIGNATORS: int = 2 + + # Map a bom key to the header + BOM_KEY_TO_COLUMNS = { + "id": "#", + "qty": "Qty", + "unit": "Unit", + "description": "Description", + "designators": "Designators", + "per_harness": "Per Harness", + } + + def __hash__(self): + try: + return hash((self.partnumbers, self.description)) + except: + import pdb + + pdb.set_trace() + + def __eq__(self, other): + return hash(self) == hash(other) + + def __add__(self, other): + # TODO: add update designators and per_harness + return BomEntry( + qty=self.qty + other.qty, + partnumbers=self.partnumbers, + id=self.id, + amount=None, # amount already included + qty_multiplier=None, # qty_multiplier already included + description=self.description, + category=self.category, + ignore_in_bom=self.ignore_in_bom, + designators=self.designators, + per_harness=self.per_harness, + ) + + def __getitem__(self, key): + if key in self.partnumbers.BOM_KEY_TO_COLUMNS: + return self.partnumbers[key] + return getattr(self, key) + + def __setitem__(self, key, value): + setattr(self, key, value) + + def __post_init__(self): + try: + assert isinstance( + self.qty, NumberAndUnit + ), f"Unexpected qty type {self.qty}" + assert isinstance( + self.partnumbers, PartNumberInfo + ), f"Unexpected partnumbers type {self.partnumbers}" + assert self.id is None or isinstance( + self.id, str + ), f"Unexpected id type {self.id}" + except AssertionError as e: + import pdb + + pdb.set_trace() + + if self.amount is not None: + assert isinstance( + self.amount, NumberAndUnit + ), f"Unexpected id type {self.amount}" + self.qty *= self.amount + if self.qty_multiplier is not None: + self.qty *= float(self.qty_multiplier) + + @property + def description_str(self): + description = self.description + if len(description) > self.MAX_PRINTED_DESCRIPTION: + description = f"{description[:self.MAX_PRINTED_DESCRIPTION]} (...)" + return description + + @property + def designators_str(self): + if not self.designators: + return "" + + all_designators = sorted(self.designators) + if len(all_designators) > self.MAX_PRINTED_DESIGNATORS: + all_designators = all_designators[: self.MAX_PRINTED_DESIGNATORS] + ["..."] + return ", ".join(all_designators) + + @property + def bom_keys(self): + return list(self.BOM_KEY_TO_COLUMNS.keys()) + self.partnumbers.bom_keys + + @property + def bom_dict(self): + d = {} + for k in self.bom_keys: + # Some keys require custom handling, others use default value + if k == "id": + d[k] = str(self.id) + elif k == "qty": + d[k] = self.qty.number_str + elif k == "unit": + d[k] = self.qty.unit_str + elif k == "description": + d[k] = self.description_str + elif k == "designators": + d[k] = self.designators_str + elif k == "per_harness": + content = [ + f'{name}: {info["qty"]}' for name, info in self.per_harness.items() + ] + if len(content) > 1: + d[k] = ", ".join(content) + else: + d[k] = self[k] + return d + + @property + def bom_defined(self): + d = self.bom_dict + return {k for k, v in d.items() if v != ""} + + def bom_column(self, key): + if key in self.BOM_KEY_TO_COLUMNS: + return self.BOM_KEY_TO_COLUMNS[key] + if key in self.partnumbers.BOM_KEY_TO_COLUMNS: + return self.partnumbers.BOM_KEY_TO_COLUMNS[key] + raise ValueError(f"key '{key}' not found in bom keys") + + @dataclass class Options: fontname: PlainText = "arial" @@ -86,21 +402,13 @@ class Options: _image_paths: [List] = field(default_factory=list) def __post_init__(self): - self.bgcolor = SingleColor(self.bgcolor) - self.bgcolor_node = SingleColor(self.bgcolor_node) - self.bgcolor_connector = SingleColor(self.bgcolor_connector) - self.bgcolor_cable = SingleColor(self.bgcolor_cable) - self.bgcolor_bundle = SingleColor(self.bgcolor_bundle) - - if not self.bgcolor_node: - self.bgcolor_node = self.bgcolor - if not self.bgcolor_connector: - self.bgcolor_connector = self.bgcolor_node - if not self.bgcolor_cable: - self.bgcolor_cable = self.bgcolor_node - if not self.bgcolor_bundle: - self.bgcolor_bundle = self.bgcolor_cable + self.bgcolor_node = SingleColor(self.bgcolor_node) or self.bgcolor + self.bgcolor_connector = ( + SingleColor(self.bgcolor_connector) or self.bgcolor_node + ) + self.bgcolor_cable = SingleColor(self.bgcolor_cable) or self.bgcolor_node + self.bgcolor_bundle = SingleColor(self.bgcolor_bundle) or self.bgcolor_cable @dataclass @@ -161,6 +469,8 @@ class PinClass: _anonymous: bool = False # true for pins on autogenerated connectors _simple: bool = False # true for simple connector + # TODO: support a "crimp" defined by parent + def __str__(self): snippets = [ # use str() for each in case they are int or other non-str str(self.parent) if not self._anonymous else "", @@ -169,6 +479,10 @@ class PinClass: ] return ":".join([snip for snip in snippets if snip != ""]) + @property + def category(self): + return BomCategory.PIN + @dataclass class Component: @@ -176,8 +490,6 @@ class Component: 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 @@ -185,146 +497,105 @@ class Component: mpn: str = None supplier: str = None spn: str = None + # BOM info qty: NumberAndUnit = NumberAndUnit(1, None) amount: Optional[NumberAndUnit] = None - sum_amounts_in_bom: bool = True ignore_in_bom: bool = False - bom_id: Optional[str] = None # to be filled after harness is built + id: Optional[str] = None # to be filled after harness is built + designators: [List] = field( + default_factory=list + ) # Used only for additional components - def fill_partnumbers(self): - partnos = [self.pn, self.manufacturer, self.mpn, self.supplier, self.spn] - partnos = [remove_links(entry) for entry in partnos] - partnos = tuple(partnos) - self.partnumbers = PartNumberInfo(*partnos) - - def parse_number_and_unit( - self, - inp: Optional[Union[NumberAndUnit, float, int, str]], - default_unit: Optional[str] = None, - ) -> Optional[NumberAndUnit]: - if inp is None: - return None - elif isinstance(inp, NumberAndUnit): - return inp - elif isinstance(inp, float) or isinstance(inp, int): - return NumberAndUnit(float(inp), default_unit) - elif isinstance(inp, str): - if " " in inp: - number, unit = inp.split(" ", 1) - else: - number, unit = inp, default_unit - try: - number = float(number) - except ValueError: - raise Exception( - f"{inp} is not a valid number and unit.\n" - "It must be a number, or a number and unit separated by a space." - ) - else: - return NumberAndUnit(number, unit) - - @property - def bom_hash(self) -> BomHash: - if self.sum_amounts_in_bom: - _hash = BomHash( - description=self.description, - qty_unit=self.amount.unit if self.amount else None, - amount=None, - partnumbers=self.partnumbers, - ) - else: - _hash = BomHash( - description=self.description, - qty_unit=self.qty.unit, - amount=self.amount, - partnumbers=self.partnumbers, - ) - return _hash - - @property - def bom_qty(self) -> float: - if self.sum_amounts_in_bom: - if self.amount: - return self.qty.number * self.amount.number - else: - return self.qty.number - else: - return self.qty.number - - def bom_amount(self) -> NumberAndUnit: - if self.sum_amounts_in_bom: - return NumberAndUnit(None, None) - else: - return self.amount - - -@dataclass -class AdditionalComponent(Component): + # Utility + parent: Optional = None + additional_components: Optional[List] = field(default_factory=list) qty_multiplier: Union[QtyMultiplierConnector, QtyMultiplierCable, int] = 1 _qty_multiplier_computed: Union[int, float] = 1 - 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) - self.qty = self.parse_number_and_unit(self.qty, None) - self.amount = self.parse_number_and_unit(self.amount, None) + # style + bgcolor: SingleColor = None - if isinstance(self.qty_multiplier, float) or isinstance( - self.qty_multiplier, int - ): - pass - else: - self.qty_multiplier = self.qty_multiplier.upper() - if self.qty_multiplier in QtyMultiplierConnector.__members__.keys(): - self.qty_multiplier = QtyMultiplierConnector[self.qty_multiplier] - elif self.qty_multiplier in QtyMultiplierCable.__members__.keys(): - self.qty_multiplier = QtyMultiplierCable[self.qty_multiplier] - else: - raise Exception(f"Unknown qty multiplier: {self.qty_multiplier}") + def __hash__(self): + """Provide a hash for this component dataclass. - @property - def additional_components(self): - # an additional component may not have further nested additional comonents - return [] + Any component using same part should have the same hash + """ + return hash(self.partnumbers) - @property - def bom_qty(self): - return self.qty.number * self._qty_multiplier_computed - - @property - def description(self) -> str: + def __str__(self) -> str: return f"{self.type}{', ' + self.subtype if self.subtype else ''}" + def __post_init__(self): + self.qty = NumberAndUnit.to_number_and_unit(self.qty) + self.amount = NumberAndUnit.to_number_and_unit(self.amount) + if isinstance(self.pn, list): + import pdb -@dataclass -class GraphicalComponent(Component): # abstract class, for future use - bgcolor: Optional[SingleColor] = None + pdb.set_trace() + + for i, item in enumerate(self.additional_components): + if isinstance(item, Component): + continue + elif isinstance(item, dict): + self.additional_components[i] = Component( + **item, category=BomCategory.ADDITIONAL, parent=self + ) + else: + raise ValueError( + f"additional component {item} should be a Component or a dict, is {type(item)}" + ) + + if self.category is None: + raise RuntimeError(f"category should be defined for {self}") + + @property + def bom_entry(self): + return BomEntry( + qty=self.qty, + partnumbers=self.partnumbers, + id=self.id, + amount=self.amount, + qty_multiplier=self._qty_multiplier_computed, + description=str(self), + category=self.category, + designators=self.designators, + ignore_in_bom=self.ignore_in_bom, + ) + + @property + def partnumbers(self): + partnos = [self.pn, self.manufacturer, self.mpn, self.supplier, self.spn] + partnos = [remove_links(entry) for entry in partnos] + return PartNumberInfo(*partnos) @dataclass -class TopLevelGraphicalComponent(GraphicalComponent): # abstract class +class GraphicalComponent(Component): # abstract class # component properties designator: Designator = None color: Optional[MultiColor] = None image: Optional[Image] = None - additional_components: List[AdditionalComponent] = field(default_factory=list) + additional_components: List[Component] = field(default_factory=list) notes: Optional[MultilineHypertext] = None # BOM options add_up_in_bom: Optional[bool] = None # rendering options + bgcolor: Optional[SingleColor] = None bgcolor_title: Optional[SingleColor] = None show_name: Optional[bool] = None + def __hash__(self): + return hash(super()) + + def __post_init__(self): + super().__post_init__() + @dataclass -class Connector(TopLevelGraphicalComponent): +class Connector(GraphicalComponent): # connector-specific properties style: Optional[str] = None - category: Optional[str] = None loops: List[List[Pin]] = field(default_factory=list) # pin information in particular pincount: Optional[int] = None @@ -336,12 +607,10 @@ class Connector(TopLevelGraphicalComponent): show_pincount: Optional[bool] = None hide_disconnected_pins: bool = False - @property - def is_autogenerated(self): - return self.designator.startswith(AUTOGENERATED_PREFIX) + def __hash__(self): + return hash(super()) - @property - def description(self) -> str: + def __str__(self) -> str: substrs = [ "Connector", self.type, @@ -351,19 +620,19 @@ class Connector(TopLevelGraphicalComponent): ] return ", ".join([str(s) for s in substrs if s is not None and s != ""]) + @property + def is_autogenerated(self): + return self.designator.startswith(AUTOGENERATED_PREFIX) + def should_show_pin(self, pin_id): return ( not self.hide_disconnected_pins or self.pin_objects[pin_id]._num_connections > 0 ) - @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.category = BomCategory.CONNECTOR + super().__post_init__() self.bgcolor = SingleColor(self.bgcolor) self.bgcolor_title = SingleColor(self.bgcolor_title) @@ -439,10 +708,6 @@ class Connector(TopLevelGraphicalComponent): self.activate_pin(loop[0], side=None, is_connection=True) self.activate_pin(loop[1], side=None, is_connection=True) - for i, item in enumerate(self.additional_components): - if isinstance(item, dict): - self.additional_components[i] = AdditionalComponent(**item) - def activate_pin(self, pin_id, side: Side = None, is_connection=True) -> None: if is_connection: self.pin_objects[pin_id]._num_connections += 1 @@ -475,52 +740,23 @@ class Connector(TopLevelGraphicalComponent): @dataclass -class WireClass: - parent: str # designator of parent cable/bundle +class WireClass(GraphicalComponent): + parent: str = None # designator of parent cable/bundle # wire-specific properties - index: int - id: str - label: str - color: MultiColor - # ... - bom_id: Optional[str] = None # to be filled after harness is built + index: int = None + label: str = "" + color: MultiColor = None # inheritable from parent cable type: Union[MultilineHypertext, List[MultilineHypertext]] = None subtype: Union[MultilineHypertext, List[MultilineHypertext]] = None gauge: Optional[NumberAndUnit] = None length: Optional[NumberAndUnit] = None ignore_in_bom: Optional[bool] = False - sum_amounts_in_bom: bool = True - partnumbers: PartNumberInfo = None - @property - def bom_hash(self) -> BomHash: - if self.sum_amounts_in_bom: - _hash = BomHash( - description=self.description, - qty_unit=self.length.unit if self.length else None, - amount=None, - partnumbers=self.partnumbers, - ) - else: - _hash = BomHash( - description=self.description, - qty_unit=None, - amount=self.length, - partnumbers=self.partnumbers, - ) - return _hash + def __hash__(self): + return hash((self.partnumbers, self.gauge_str, str(self.color))) - @property - def gauge_str(self): - if not self.gauge: - return None - actual_gauge = f"{self.gauge.number} {self.gauge.unit}" - actual_gauge = actual_gauge.replace("mm2", "mm\u00B2") - return actual_gauge - - @property - def description(self) -> str: + def __str__(self) -> str: substrs = [ "Wire", self.type, @@ -531,46 +767,26 @@ class WireClass: desc = ", ".join([s for s in substrs if s is not None and s != ""]) return desc - -@dataclass -class ShieldClass(WireClass): - pass # TODO, for wires with multiple shields more shield details, ... - - -@dataclass -class Connection: - from_: PinClass = None - via: Union[WireClass, ShieldClass] = None - to: PinClass = None - - -@dataclass -class Cable(TopLevelGraphicalComponent): - # cable-specific properties - gauge: Optional[NumberAndUnit] = None - length: Optional[NumberAndUnit] = None - color_code: Optional[str] = None - # wire information in particular - wirecount: Optional[int] = None - shield: Union[bool, MultiColor] = False - colors: List[str] = field(default_factory=list) # legacy - wirelabels: List[Wire] = field(default_factory=list) # legacy - wire_objects: Dict[Any, WireClass] = field(default_factory=dict) # new - # 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 + def __post_init__(self): + self.category = BomCategory.WIRE @property - def is_autogenerated(self): - return self.designator.startswith(AUTOGENERATED_PREFIX) + def partnumbers(self): + _partnumbers = super().partnumbers + if not _partnumbers.mpn and self.color is not None: + _partnumbers.mpn = self.get_mpn_if_belden() + return _partnumbers @property - def unit(self): # for compatibility with parent class - return self.length + def bom_entry(self): + return BomEntry( + qty=self.length, + partnumbers=self.partnumbers, + id=self.id, + description=str(self), + category=self.category, + ignore_in_bom=self.ignore_in_bom, + ) @property def gauge_str(self): @@ -603,34 +819,7 @@ class Cable(TopLevelGraphicalComponent): def length_str(self): if not self.length: return None - out = f"{self.length.number} {self.length.unit}" - return out - - @property - def bom_hash(self): - if self.category == "bundle": - raise Exception("Do this at the wire level!") # TODO - else: - return super().bom_hash - - @property - def description(self) -> str: - if self.category == "bundle": - raise Exception("Do this at the wire level!") # TODO - else: - substrs = [ - ("", "Cable"), - (", ", self.type), - (", ", self.subtype), - (", ", self.wirecount), - (" ", f"x {self.gauge_str}" 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 + return str(self.length) belden_color = { "BN": "001", @@ -684,12 +873,12 @@ class Cable(TopLevelGraphicalComponent): def get_belden_color(self, color): if color not in self.belden_color: logging.warn( - f"No color found in belden colors {list(self.belden_color.keys())} matching {self.color}, defaulting to BK" + f"{self}: Color '{self.color}' not found in belden colors {list(self.belden_color.keys())}, defaulting to BK" ) return self.belden_color["BK"] return self.belden_color[color] - def gen_belden_cable_with_alternate(self, color): + def gen_belden_cable_with_alternate(self): # Gauge and mpn base try: parts = self.belden_tfe_base_mpn[self.gauge_str] @@ -698,10 +887,10 @@ class Cable(TopLevelGraphicalComponent): f"Couldn't find a belden TFE wire for wire of {self.gauge_str}" ) - color = self.get_belden_color(color) + color = self.get_belden_color(str(self.color)) if not color: - raise ValueError(f"Failed to find a color for property: {self.description}") + raise ValueError(f"Failed to find a color for property: {self}") # Create the list of mpn roll_length = 100 @@ -711,10 +900,11 @@ class Cable(TopLevelGraphicalComponent): alternates = mpn_list[1:] if len(mpn_list) > 1 else [] return (main_part, alternates) - def get_mpn_if_belden(self, manufacturer, mpn, color): + # TODO: clean me up + def get_mpn_if_belden(self): if self.manufacturer and not self.mpn: if self.is_belden: - main_part, alternates = self.gen_belden_cable_with_alternate(color) + main_part, alternates = self.gen_belden_cable_with_alternate() return main_part if alternates: logging.info( @@ -726,33 +916,107 @@ class Cable(TopLevelGraphicalComponent): ) else: logging.info(f"Not updating part, no manufacturer provided") - return mpn + return self.mpn if self.mpn else "" - def _get_wire_partnumber(self, idx, color) -> PartNumberInfo: - def _get_correct_element(inp, idx): - return inp[idx] if isinstance(inp, List) else inp - # TODO: possibly make more robust/elegant - if self.category == "bundle": - manufacturer = (_get_correct_element(self.partnumbers.manufacturer, idx),) - mpn = (_get_correct_element(self.partnumbers.mpn, idx),) - if color is not None: - mpn = self.get_mpn_if_belden(manufacturer, mpn, color.code_en) +@dataclass +class ShieldClass(WireClass): + pass # TODO, for wires with multiple shields more shield details, ... - return PartNumberInfo( - _get_correct_element(self.partnumbers.pn, idx), - _get_correct_element(self.partnumbers.manufacturer, idx), - mpn, - _get_correct_element(self.partnumbers.supplier, idx), - _get_correct_element(self.partnumbers.spn, idx), - ) + def __hash__(self): + return hash(self.partnumbers) + + +@dataclass +class Connection: + from_: PinClass = None + via: Union[WireClass, ShieldClass] = None + to: PinClass = None + + +@dataclass +class Cable(WireClass): + # cable-specific properties + gauge: Optional[NumberAndUnit] = None + length: Optional[NumberAndUnit] = None + color_code: Optional[str] = None + # wire information in particular + wirecount: Optional[int] = None + shield: Union[bool, MultiColor] = False + colors: List[str] = field(default_factory=list) # legacy + wirelabels: List[Wire] = field(default_factory=list) # legacy + wire_objects: Dict[Any, WireClass] = field(default_factory=dict) # new + # internal + _connections: List[Connection] = field(default_factory=list) + # rendering options + show_name: Optional[bool] = None + show_equiv: bool = False + show_wirecount: bool = True + + def __hash__(self): + if self.is_bundle: + return hash(tuple([hash(w) for w in self.wire_objects.values()])) else: - return None # non-bundles do not support lists of part data + return hash(super()) + + def __str__(self) -> str: + substrs = [ + ("", "Cable"), + (", ", self.type), + (", ", self.subtype), + (", ", self.wirecount), + (" ", f"x {self.gauge_str}" if self.gauge else "wires"), + (" ", "shielded" if self.shield else None), + (", ", str(self.color) if self.color else None), + ] + if self.is_bundle: + substrs += [ + (f"\n\t{i}: ", w) for i, w in enumerate(self.wire_objects.values()) + ] + desc = "".join( + [f"{s[0]}{s[1]}" for s in substrs if s[1] is not None and s[1] != ""] + ) + return desc + + @property + def partnumbers(self): + if self.is_bundle: + return [w.partnumbers for w in self.wire_objects.values()] + else: + return super().partnumbers + + @property + def bom_entry(self): + if self.is_bundle: + return [w.bom_entry for w in self.wire_objects.values()] + else: + return BomEntry( + qty=self.qty, + partnumbers=self.partnumbers, + id=self.id, + amount=self.amount, + qty_multiplier=self._qty_multiplier_computed, + description=str(self), + category=self.category, + designators=self.designators, + ignore_in_bom=self.ignore_in_bom, + ) + + @property + def is_bundle(self): + return self.category == BomCategory.BUNDLE + + @property + def is_autogenerated(self): + return self.designator.startswith(AUTOGENERATED_PREFIX) def __post_init__(self) -> None: + if isinstance(self.category, str) and self.category.lower() == "bundle": + self.category = BomCategory.BUNDLE + else: + self.category = BomCategory.CABLE - super().fill_partnumbers() - + # TODO: style management should be separated from this logic... self.bgcolor = SingleColor(self.bgcolor) self.bgcolor_title = SingleColor(self.bgcolor_title) self.color = MultiColor(self.color) @@ -764,8 +1028,8 @@ class Cable(TopLevelGraphicalComponent): # allow gauge, length, and other fields to be lists too (like part numbers), # and assign them the same way to bundles. - self.gauge = self.parse_number_and_unit(self.gauge, "mm2") - self.length = self.parse_number_and_unit(self.length, "m") + self.gauge = NumberAndUnit.to_number_and_unit(self.gauge, "mm2") + self.length = NumberAndUnit.to_number_and_unit(self.length, "m") self.amount = self.length # for BOM if self.wirecount: # number of wires explicitly defined @@ -781,6 +1045,8 @@ class Cable(TopLevelGraphicalComponent): get_color_by_colorcode_index(self.color_code, i) for i in range(self.wirecount) ] + elif self.color: + self.colors = [self.color[w] for w in range(self.wirecount)] else: # no colors defined, add dummy colors self.colors = [""] * self.wirecount @@ -802,7 +1068,7 @@ class Cable(TopLevelGraphicalComponent): # check this is a bundle and that it matches the wirecount. for idfield in [self.manufacturer, self.mpn, self.supplier, self.spn, self.pn]: if isinstance(idfield, list): - if self.category == "bundle": + if self.is_bundle: # check the length if len(idfield) != self.wirecount: raise Exception("lists of part data must match wirecount") @@ -817,12 +1083,24 @@ class Cable(TopLevelGraphicalComponent): ) for wire_index, (wire_color, wire_label) in enumerate(wire_tuples): id = wire_index + 1 - color = MultiColor(wire_color) + color = MultiColor(wire_color)[wire_index] + by_idx = lambda x: x[wire_index] if isinstance(x, list) else x + pn = by_idx(self.pn) + manufacturer = by_idx(self.manufacturer) + mpn = by_idx(self.mpn) + supplier = by_idx(self.supplier) + spn = by_idx(self.spn) + self.wire_objects[id] = WireClass( + pn=pn, + manufacturer=manufacturer, + mpn=mpn, + supplier=supplier, + spn=spn, parent=self.designator, # wire-specific properties index=wire_index, # TODO: wire_id - id=id, # TODO: wire_id + id=str(id), # TODO: wire_id label=wire_label, color=color, # inheritable from parent cable @@ -830,9 +1108,7 @@ class Cable(TopLevelGraphicalComponent): subtype=self.subtype, gauge=self.gauge, length=self.length, - sum_amounts_in_bom=self.sum_amounts_in_bom, ignore_in_bom=self.ignore_in_bom, - partnumbers=self._get_wire_partnumber(wire_index, color[wire_index]), ) if self.shield: @@ -852,13 +1128,11 @@ class Cable(TopLevelGraphicalComponent): if self.show_name is None: self.show_name = not self.is_autogenerated - if self.show_wirenumbers is None: - # by default, show wire numbers for cables, hide for bundles - self.show_wirenumbers = self.category != "bundle" - for i, item in enumerate(self.additional_components): if isinstance(item, dict): - self.additional_components[i] = AdditionalComponent(**item) + self.additional_components[i] = Component( + **item, category=BomCategory.ADDITIONAL, parent=self.designator + ) def _connect( self,