# -*- coding: utf-8 -*- import logging from dataclasses import dataclass, field 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_colors import ( COLOR_CODES, ColorOutputMode, MultiColor, SingleColor, get_color_by_colorcode_index, ) from wireviz.wv_utils import aspect_ratio, awg_equiv, mm2_equiv, remove_links # 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 ImageScale = PlainText # = Literal['false', 'true', 'width', 'height', 'both'] # Type combinations Pin = Union[int, PlainText] # Pin identifier PinIndex = int # Zero-based pin index Wire = Union[int, PlainText] # Wire number or Literal['s'] for shield NoneOrMorePins = Union[ Pin, Tuple[Pin, ...], None ] # None, one, or a tuple of pin identifiers NoneOrMorePinIndices = Union[ PinIndex, Tuple[PinIndex, ...], None ] # None, one, or a tuple of zero-based pin indices OneOrMoreWires = Union[Wire, Tuple[Wire, ...]] # One or a tuple of wires # Metadata can contain whatever is needed by the HTML generation/template. 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") 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" ) # 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 __bool__(self): return bool(self.pn or self.manufacturer or self.mpn or self.supplier or self.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): raise ValueError(f'pn ({self.pn}) should not be a list') 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 clear_per_field(self, op, other): part = self.copy() if other is None: if op == '==': return part elif op == '!=': return None else: raise NotImplementedError(f'op {op} not supported') if isinstance(other, list): for item in other: part = part.clear_per_field(op, item) else: for k in ["pn", "manufacturer", "mpn", "supplier", "spn"]: if op == '==': if part[k] == other[k]: part[k] = "" elif op == '!=': if part[k] != other[k]: part[k] = "" else: raise NotImplementedError(f'op {op} not supported') return part def keep_only_eq(self, other): return self.clear_per_field('!=', other) def remove_eq(self, other): return self.clear_per_field('==', other) @staticmethod def list_keep_only_eq(partnumbers): pn = partnumbers[0] for p in partnumbers: pn = pn.keep_only_eq(p) return pn @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 restrict_printed_lengths: bool = True scaled_per_harness = False # 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 __repr__(self): return f'{id}: {self.partnumbers}, {self.qty}' def __hash__(self): return hash((self.partnumbers, self.description)) 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): 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}" 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: if not isinstance(self.qty_multiplier, str): self.qty *= float(self.qty_multiplier) @property def description_str(self): description = self.description if self.restrict_printed_lengths and 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 self.restrict_printed_lengths and 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") def scale_per_harness(self, qty_multipliers): if self.scaled_per_harness: logging.warn('{self}: Already scaled') qty = NumberAndUnit(0, self.qty.unit_str) for name, info in self.per_harness.items(): multiplier_name = [k for k in qty_multipliers.keys() if name.endswith(k)] if len(multiplier_name) == 0: raise ValueError(f'No multiplier found for harness {name} in {qty_multipliers}') if len(multiplier_name) > 1: raise ValueError(f'Conflicting multipliers found ({multiplier_name}) for harness {name} in {qty_multipliers}') info['qty'] *= qty_multipliers[multiplier_name[0]] qty += info['qty'] self.qty = qty self.scaled_per_harness = True @dataclass class Options: fontname: PlainText = "arial" notes_to_right: bool = True bgcolor: SingleColor = "WH" # will be converted to SingleColor in __post_init__ bgcolor_node: SingleColor = "WH" bgcolor_connector: SingleColor = None bgcolor_cable: SingleColor = None bgcolor_bundle: SingleColor = None color_output_mode: ColorOutputMode = ColorOutputMode.EN_UPPER mini_bom_mode: bool = True template_separator: str = "." _pad: int = 0 # TODO: resolve template and image paths during rendering, not during YAML parsing _template_paths: [List] = field(default_factory=list) _image_paths: [List] = field(default_factory=list) def __post_init__(self): self.bgcolor = SingleColor(self.bgcolor) 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 class Image: # Attributes of the image object : src: str scale: Optional[ImageScale] = None # Attributes of the image cell containing the image: width: Optional[int] = None height: Optional[int] = None fixedsize: Optional[bool] = None bgcolor: SingleColor = None # Contents of the text cell just below the image cell: caption: Optional[MultilineHypertext] = None # See also HTML doc at https://graphviz.org/doc/info/shapes.html#html def __post_init__(self): self.bgcolor = SingleColor(self.bgcolor) if self.fixedsize is None: # Default True if any dimension specified unless self.scale also is specified. self.fixedsize = (self.width or self.height) and self.scale is None if self.scale is None: if not self.width and not self.height: self.scale = "false" elif self.width and self.height: self.scale = "both" else: self.scale = "true" # When only one dimension is specified. if self.fixedsize: # If only one dimension is specified, compute the other # because Graphviz requires both when fixedsize=True. if self.height: if not self.width: self.width = self.height * aspect_ratio(self.src) else: if self.width: self.height = self.width / aspect_ratio(self.src) @dataclass class PinClass: index: int id: str label: str color: MultiColor parent: str # designator of parent connector _num_connections = 0 # incremented in Connector.connect() _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 "", str(self.id) if not self._anonymous and not self._simple else "", str(self.label) if self.label else "", ] return ":".join([snip for snip in snippets if snip != ""]) @property def category(self): return BomCategory.PIN @dataclass 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 # 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 # BOM info qty: NumberAndUnit = NumberAndUnit(1, None) amount: Optional[NumberAndUnit] = None ignore_in_bom: bool = False id: Optional[str] = None # to be filled after harness is built designators: [List] = field( default_factory=list ) # Used only for additional components # 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 # style bgcolor: SingleColor = None def __hash__(self): """Provide a hash for this component dataclass. Any component using same part should have the same hash """ return hash(self.partnumbers) 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): raise RuntimeError(f'PN ({self.pn}) should not be a list') 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}") def compute_qty_multipliers(self): pass @property def bom_entry(self): self.compute_qty_multipliers() 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 GraphicalComponent(Component): # abstract class # component properties designator: Designator = None color: Optional[MultiColor] = None image: Optional[Image] = None 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(GraphicalComponent): # connector-specific properties style: Optional[str] = None loops: List[List[Pin]] = field(default_factory=list) # pin information in particular pincount: Optional[int] = 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: Dict[Any, PinClass] = field(default_factory=dict) # new # rendering option show_pincount: Optional[bool] = None hide_disconnected_pins: bool = False def __hash__(self): return hash(super()) def __str__(self) -> str: substrs = [ "Connector", self.type, self.subtype, f"{self.pincount} pins" 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 != ""]) @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 ) def __post_init__(self) -> None: self.category = BomCategory.CONNECTOR super().__post_init__() self.bgcolor = SingleColor(self.bgcolor) self.bgcolor_title = SingleColor(self.bgcolor_title) self.color = MultiColor(self.color) # connectors do not support custom qty or amount self.qty = NumberAndUnit(1, None) self.amount = None if isinstance(self.image, dict): self.image = Image(**self.image) self.ports_left = False self.ports_right = False self.visible_pins = {} if self.style == "simple": if self.pincount and self.pincount > 1: raise Exception( "Connectors with style set to simple may only have one pin" ) self.pincount = 1 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 list for pins (sequential) if not specified if not self.pins: self.pins = list(range(1, self.pincount + 1)) if len(self.pins) != len(set(self.pins)): raise Exception("Pins are not unique") # all checks have passed pin_tuples = zip_longest( self.pins, self.pinlabels, self.pincolors, ) for pin_index, (pin_id, pin_label, pin_color) in enumerate(pin_tuples): self.pin_objects[pin_id] = PinClass( index=pin_index, id=pin_id, label=pin_label, color=MultiColor(pin_color), parent=self.designator, _anonymous=self.is_autogenerated, _simple=self.style == "simple", ) if self.show_name is None: self.show_name = self.style != "simple" and not self.is_autogenerated if self.show_pincount is None: # hide pincount for simple (1 pin) connectors by default self.show_pincount = self.style != "simple" for loop in self.loops: # TODO: check that pins to connect actually exist # TODO: allow using pin labels in addition to pin numbers, # just like when defining regular connections # TODO: include properties of wire used to create the loop if len(loop) != 2: raise Exception("Loops must be between exactly two pins!") # side=None, determine side to show loops during rendering self.activate_pin(loop[0], side=None, is_connection=True) self.activate_pin(loop[1], side=None, is_connection=True) def activate_pin(self, pin_id, side: Side = None, is_connection=True) -> None: if is_connection: self.pin_objects[pin_id]._num_connections += 1 if side == Side.LEFT: self.ports_left = True elif side == Side.RIGHT: self.ports_right = True def compute_qty_multipliers(self): # do not run before all connections in harness have been made! num_populated_pins = len( [pin for pin in self.pin_objects.values() if pin._num_connections > 0] ) num_connections = sum( [pin._num_connections for pin in self.pin_objects.values()] ) qty_multipliers_computed = { "PINCOUNT": self.pincount, "POPULATED": num_populated_pins, "CONNECTIONS": num_connections, } for subitem in self.additional_components: if isinstance(subitem.qty_multiplier, str): computed_factor = qty_multipliers_computed[subitem.qty_multiplier.upper()] #if isinstance(subitem.qty_multiplier, QtyMultiplierConnector): # computed_factor = qty_multipliers_computed[subitem.qty_multiplier.name.upper()] #elif isinstance(subitem.qty_multiplier, QtyMultiplierCable): # raise Exception("Used a cable multiplier in a connector!") elif isinstance(subitem.qty_multiplier, int) or isinstance(subitem.qty_multiplier, float): computed_factor = subitem.qty_multiplier else: raise ValueError(f'Unexpected qty multiplier "{subitem.qty_multiplier}"') subitem._qty_multiplier_computed = computed_factor @dataclass class WireClass(GraphicalComponent): parent: str = None # designator of parent cable/bundle # wire-specific properties 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 def __hash__(self): return hash((self.partnumbers, self.gauge_str, str(self.color))) def __str__(self) -> str: substrs = [ "Wire", self.type, self.subtype, self.gauge_str, str(self.color) if self.color else None, ] desc = ", ".join([s for s in substrs if s is not None and s != ""]) return desc def __post_init__(self): self.category = BomCategory.WIRE @property 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 bom_entry(self): self.compute_qty_multipliers() 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): if not self.gauge: return None number = ( int(self.gauge.number) if self.gauge.unit == "AWG" else self.gauge.number ) actual_gauge = f"{number} {self.gauge.unit}" actual_gauge = actual_gauge.replace("mm2", "mm\u00B2") return actual_gauge @property def gauge_str_with_equiv(self): if not self.gauge: return None actual_gauge = self.gauge_str equivalent_gauge = "" if self.show_equiv: # convert unit if known if self.gauge.unit == "mm2": equivalent_gauge = f" ({awg_equiv(self.gauge.number)} AWG)" elif self.gauge.unit.upper() == "AWG": equivalent_gauge = f" ({mm2_equiv(self.gauge.number)} mm2)" out = f"{actual_gauge}{equivalent_gauge}" out = out.replace("mm2", "mm\u00B2") return out @property def length_str(self): if not self.length: return None return str(self.length) belden_color = { "BN": "001", "RD": "002", "OG": "003", "YE": "004", "GN": "005", "TQ": "006", # (For Belden: light blue. For WireViz: turquoise) "VT": "007", "GY": "008", "WH": "009", "BK": "010", "BG": "011", "PK": "012", "BU": "013", "BKRD": "015", # (for Belden: white/red) "BKGN": "016", # (for Belden: white/green) "BKYE": "017", # (for Belden: white/yellow) "BKBU": "018", # (for Belden: white/blue) "BKBN": "019", # (for Belden: white/brown) "BKOG": "020", # (for Belden: white/orange) "BKGY": "021", # (for Belden: white/gray) "BKVT": "022", # (for Belden: white/purple) # (1) Why use BKRD instead of WHRD, since Belden only sells white/red? # - WHRD is impractical to use in Wireviz (white wire sides on white background does not help with identification) # - BKRD is clearly distinguishable, and comes handy to use in Wireviz, as the representation of a GND rail associated (even twisted, if applicable) with a specific RD signal wire. # (2) For all wire colors see: # https://www.belden.com/dfsmedia/f1e38517e0cd4caa8b1acb6619890f5e/7806-source/options/view/cabling-solutions-for-industrial-applications-catalog-belden-09-2020#page=153 } belden_tfe_base_mpn = { # Leftmost in list is the prefered MPN # NOTE (lal 2022-12-20): this prefered MPN is arbitrary ATM "16 AWG": ["83030", "83010"], "18 AWG": ["83029", "83009"], "20 AWG": ["83028", "83027", "83007", "83008"], "22 AWG": ["83025", "83026", "83005", "83006", "83049", "83050"], "24 AWG": ["83023", "83003", "83004", "83047", "83048"], "26 AWG": ["83002", "83046"], "28 AWG": ["83001", "83045"], "30 AWG": ["83000", "83043"], "32 AWG": ["83041"], # see: https://www.belden.com/dfsmedia/f1e38517e0cd4caa8b1acb6619890f5e/7806-source/options/view/cabling-solutions-for-industrial-applications-catalog-belden-09-2020#page=136 } @property def is_belden(self): if "belden" in self.manufacturer.lower(): return True return False def get_belden_color(self, color): if color not in self.belden_color: logging.warn( 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): # Gauge and mpn base try: parts = self.belden_tfe_base_mpn[self.gauge_str] except KeyError: raise ValueError( f"Couldn't find a belden TFE wire for wire of {self.gauge_str}" ) color = self.get_belden_color(str(self.color)) if not color: raise ValueError(f"Failed to find a color for property: {self}") # Create the list of mpn roll_length = 100 mpn_list = [f"{mpn} {color}{roll_length}" for mpn in parts] main_part = mpn_list[0] alternates = mpn_list[1:] if len(mpn_list) > 1 else [] return (main_part, alternates) # 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() return main_part if alternates: logging.info( f'Alternate part{"s" if len(alternates) > 1 else ""} available for {self.gauge_str}, color {self.color}: {alternates}' ) else: logging.info( f'Not updating part for manufacturer {self.manufacturer}, only "belden" supported' ) else: logging.info(f"Not updating part, no manufacturer provided") return self.mpn if self.mpn else "" @dataclass class ShieldClass(WireClass): pass # TODO, for wires with multiple shields more shield details, ... 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 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): self.compute_qty_multipliers() 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 # 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) if isinstance(self.image, dict): self.image = Image(**self.image) # TODO: # allow gauge, length, and other fields to be lists too (like part numbers), # and assign them the same way to bundles. 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 if self.colors: # use custom color palette (partly or looped if needed) self.colors = [ self.colors[i % len(self.colors)] for i in range(self.wirecount) ] elif self.color_code: # use standard color palette (partly or looped if needed) if self.color_code not in COLOR_CODES: raise Exception("Unknown color code") self.colors = [ 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 else: # wirecount implicit in length of color list if not self.colors: 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.supplier, self.spn, self.pn]: if isinstance(idfield, list): if self.is_bundle: # check the length if len(idfield) != self.wirecount: raise Exception("lists of part data must match wirecount") else: raise Exception("lists of part data are only supported for bundles") # all checks have passed wire_tuples = zip_longest( # TODO: self.wire_ids self.colors, self.wirelabels, ) for wire_index, (wire_color, wire_label) in enumerate(wire_tuples): id = wire_index + 1 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=str(id), # TODO: wire_id label=wire_label, color=color, # inheritable from parent cable type=self.type, subtype=self.subtype, gauge=self.gauge, length=self.length, ignore_in_bom=self.ignore_in_bom, ) if self.shield: index_offset = len(self.wire_objects) # TODO: add support for multiple shields id = "s" self.wire_objects[id] = ShieldClass( index=index_offset, id=id, label="Shield", color=MultiColor(self.shield) if isinstance(self.shield, str) else MultiColor(None), parent=self.designator, ) if self.show_name is None: self.show_name = not self.is_autogenerated for i, item in enumerate(self.additional_components): if isinstance(item, dict): self.additional_components[i] = Component( **item, category=BomCategory.ADDITIONAL, parent=self.designator ) def _connect( self, from_pin_obj: [PinClass], via_wire_id: str, to_pin_obj: [PinClass], ) -> None: via_wire_obj = self.wire_objects[via_wire_id] self._connections.append(Connection(from_pin_obj, via_wire_obj, to_pin_obj)) def compute_qty_multipliers(self): # do not run before all connections in harness have been made! total_length = sum( [ wire.length.number if wire.length else 0 for wire in self.wire_objects.values() ] ) qty_multipliers_computed = { "WIRECOUNT": len(self.wire_objects), "TERMINATIONS": 999, # TODO "LENGTH": self.length.number if self.length else 0, "TOTAL_LENGTH": total_length, } for subitem in self.additional_components: if isinstance(subitem.qty_multiplier, QtyMultiplierCable): computed_factor = qty_multipliers_computed[subitem.qty_multiplier.name] # inherit component's length unit if appropriate if subitem.qty_multiplier.name.upper() in ["LENGTH", "TOTAL_LENGTH"]: if subitem.qty.unit is not None: raise Exception( f"No unit may be specified when using" f"{subitem.qty_multiplier} as a multiplier" ) subitem.qty = NumberAndUnit(subitem.qty.number, self.length.unit) elif isinstance(subitem.qty_multiplier, QtyMultiplierConnector): raise Exception("Used a connector multiplier in a cable!") else: # int or float computed_factor = subitem.qty_multiplier subitem._qty_multiplier_computed = computed_factor @dataclass class Arrow: direction: ArrowDirection weight: ArrowWeight @dataclass class MatePin: from_: PinClass to: PinClass arrow: Arrow @dataclass class MateComponent: from_: str # Designator to: str # Designator arrow: Arrow