1234 lines
42 KiB
Python
1234 lines
42 KiB
Python
# -*- 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 <img>:
|
|
src: str
|
|
scale: Optional[ImageScale] = None
|
|
# Attributes of the image cell <td> containing the image:
|
|
width: Optional[int] = None
|
|
height: Optional[int] = None
|
|
fixedsize: Optional[bool] = None
|
|
bgcolor: SingleColor = None
|
|
# Contents of the text cell <td> 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
|