diff --git a/examples/ex16.yml b/examples/ex16.yml new file mode 100644 index 0000000..6cb58b0 --- /dev/null +++ b/examples/ex16.yml @@ -0,0 +1,46 @@ +connectors: + X1: + type: Connector + subtype: Male + pincount: 2 + pins: [1, 2] + pinlabels: [A, B] + X2: + type: Connector + subtype: Female + pincount: 2 + pins: [1, 2] + pinlabels: [A, B] + +conduit-connectors: + X1: + type: Conduit Connector + X2: + type: Conduit Connector + +cables: + W1: + wirecount: 1 + colors: [BU] + W2: + wirecount: 1 + colors: [BN] + +conduits: + C1: + type: Conduit + ports: 2 + colors: [BU, BN] + +connections: + - - X1 + - W1 + - X2 + - - X1 + - W2 + - X2 + +conduit-connections: + - - X1 + - C1 + - X2 \ No newline at end of file diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index d14e445..4a8d007 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -55,6 +55,7 @@ class Options: bgcolor_connector: Optional[Color] = None bgcolor_cable: Optional[Color] = None bgcolor_bundle: Optional[Color] = None + bgcolor_conduit: Optional[Color] = None color_mode: ColorMode = "SHORT" mini_bom_mode: bool = True template_separator: str = "." @@ -68,6 +69,8 @@ class Options: self.bgcolor_cable = self.bgcolor_node if not self.bgcolor_bundle: self.bgcolor_bundle = self.bgcolor_cable + if not self.bgcolor_conduit: + self.bgcolor_conduit = self.bgcolor_cable @dataclass @@ -165,6 +168,15 @@ class Connector: additional_components: List[AdditionalComponent] = field(default_factory=list) def __post_init__(self) -> None: + if isinstance(self, ConduitConnector): + # ConduitConnectors don't need pins + if isinstance(self.image, dict): + self.image = Image(**self.image) + for i, item in enumerate(self.additional_components): + if isinstance(item, dict): + self.additional_components[i] = AdditionalComponent(**item) + return + if isinstance(self.image, dict): self.image = Image(**self.image) @@ -242,6 +254,11 @@ class Connector: ) +@dataclass +class ConduitConnector(Connector): + pass + + @dataclass class Cable: name: Designator @@ -272,6 +289,7 @@ class Cable: show_wirenumbers: Optional[bool] = None ignore_in_bom: bool = False additional_components: List[AdditionalComponent] = field(default_factory=list) + conduits: List[str] = None def __post_init__(self) -> None: if isinstance(self.image, dict): @@ -293,11 +311,11 @@ class Cable: if u.upper() == "AWG": self.gauge_unit = u.upper() else: - self.gauge_unit = u.replace("mm2", "mm\u00B2") + self.gauge_unit = u.replace("mm2", "mm\u00b2") elif self.gauge is not None: # gauge specified, assume mm2 if self.gauge_unit is None: - self.gauge_unit = "mm\u00B2" + self.gauge_unit = "mm\u00b2" else: pass # gauge not specified @@ -322,45 +340,54 @@ class Cable: self.connections = [] - if self.wirecount: # number of wires explicitly defined - if self.colors: # use custom color palette (partly or looped if needed) - pass - 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 = COLOR_CODES[self.color_code] - else: # no colors defined, add dummy colors - self.colors = [""] * self.wirecount + if not isinstance(self, Conduit): + if self.wirecount: # number of wires explicitly defined + if self.colors: # use custom color palette (partly or looped if needed) + pass + 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 = COLOR_CODES[self.color_code] + else: # no colors defined, add dummy colors + self.colors = [""] * self.wirecount - # make color code loop around if more wires than colors - if self.wirecount > len(self.colors): - m = self.wirecount // len(self.colors) + 1 - self.colors = self.colors * int(m) - # cut off excess after looping - self.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) + # make color code loop around if more wires than colors + if self.wirecount > len(self.colors): + m = self.wirecount // len(self.colors) + 1 + self.colors = self.colors * int(m) + # cut off excess after looping + self.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 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.category == "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") + # 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.category == "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" + ) if self.show_name is None: # hide designators for auto-generated cables by default @@ -410,6 +437,33 @@ class Cable: ) +@dataclass +class Conduit(Cable): + cables: List[Cable] = field(default_factory=list) + ports: int = 0 + cableports: dict[str] = field(default_factory=dict) + + def get_port(self, cable: Cable, port: int) -> int: + conduit_port = None + if cable.name in self.cableports: + if len(self.cableports[cable.name]) >= port: + conduit_port = self.cableports[cable.name][port - 1] + else: + self.cableports[cable.name] = [] + + if conduit_port: + return conduit_port + else: + self.cableports[cable.name].extend( + [0] * (port - len(self.cableports[cable.name])) + ) + + self.ports = self.ports + 1 + self.cableports[cable.name][port - 1] = self.ports + self.colors.append(cable.colors[port - 1]) + return self.ports + + @dataclass class Connection: from_name: Optional[Designator] diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index c4af236..27b9bf9 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -8,9 +8,12 @@ from pathlib import Path from typing import Any, List, Union from graphviz import Graph + from wireviz import APP_NAME, APP_URL, __version__, wv_colors from wireviz.DataClasses import ( Cable, + Conduit, + ConduitConnector, Connector, MateComponent, MatePin, @@ -73,7 +76,9 @@ class Harness: def __post_init__(self): self.connectors = {} + self.conduit_connectors = {} self.cables = {} + self.conduits = {} self.mates = [] self._bom = [] # Internal Cache for generated bom self.additional_bom_items = [] @@ -82,9 +87,15 @@ class Harness: check_old(f"Connector '{name}'", OLD_CONNECTOR_ATTR, kwargs) self.connectors[name] = Connector(name, *args, **kwargs) + def add_conduit_connector(self, name: str, *args, **kwargs) -> None: + self.conduit_connectors[name] = ConduitConnector(name, *args, **kwargs) + def add_cable(self, name: str, *args, **kwargs) -> None: self.cables[name] = Cable(name, *args, **kwargs) + def add_conduit(self, name: str, *args, **kwargs) -> None: + self.conduits[name] = Conduit(name, *args, **kwargs) + def add_mate_pin(self, from_name, from_pin, to_name, to_pin, arrow_type) -> None: self.mates.append(MatePin(from_name, from_pin, to_name, to_pin, arrow_type)) self.connectors[from_name].activate_pin(from_pin, Side.RIGHT) @@ -100,6 +111,7 @@ class Harness: self, from_name: str, from_pin: (int, str), + conduits: [str], via_name: str, via_wire: (int, str), to_name: str, @@ -128,7 +140,7 @@ class Harness: if not pin in connector.pins: raise Exception(f"{name}:{pin} not found.") - # check via cable + # check via cable or conduit if via_name in self.cables: cable = self.cables[via_name] # check if provided name is ambiguous @@ -153,9 +165,27 @@ class Harness: via_wire = ( cable.wirelabels.index(via_wire) + 1 ) # list index starts at 0, wire IDs start at 1 + cable.conduits = conduits + elif via_name in self.conduits: + conduit = self.conduits[via_name] + # for conduits, via_wire is the port number + if not isinstance(via_wire, int): + raise Exception( + f"{via_name}:{via_wire} must be an integer port number for conduits." + ) + if via_wire < 1 or via_wire > conduit.ports: + raise Exception(f"{via_name}:{via_wire} port out of range.") + conduit.conduits = conduits # perform the actual connection - self.cables[via_name].connect(from_name, from_pin, via_wire, to_name, to_pin) + if via_name in self.cables: + self.cables[via_name].connect( + from_name, from_pin, via_wire, to_name, to_pin + ) + elif via_name in self.conduits: + self.conduits[via_name].connect( + from_name, from_pin, via_wire, to_name, to_pin + ) if from_name in self.connectors: self.connectors[from_name].activate_pin(from_pin, Side.RIGHT) if to_name in self.connectors: @@ -302,10 +332,10 @@ class Harness: # Only convert units we actually know about, i.e. currently # mm2 and awg --- other units _are_ technically allowed, # and passed through as-is. - if cable.gauge_unit == "mm\u00B2": + if cable.gauge_unit == "mm\u00b2": awg_fmt = f" ({awg_equiv(cable.gauge)} AWG)" elif cable.gauge_unit.upper() == "AWG": - awg_fmt = f" ({mm2_equiv(cable.gauge)} mm\u00B2)" + awg_fmt = f" ({mm2_equiv(cable.gauge)} mm\u00b2)" # fmt: off rows = [[f'{html_bgcolor(cable.bgcolor_title)}{remove_links(cable.name)}' @@ -532,6 +562,79 @@ class Harness: fillcolor=translate_color(bgcolor, "HEX"), ) + for conduit in self.conduits.values(): + html = [] + + awg_fmt = "" + if conduit.show_equiv: + # Only convert units we actually know about, i.e. currently + # mm2 and awg --- other units _are_ technically allowed, + # and passed through as-is. + if conduit.gauge_unit == "mm\u00b2": + awg_fmt = f" ({awg_equiv(conduit.gauge)} AWG)" + elif conduit.gauge_unit.upper() == "AWG": + awg_fmt = f" ({mm2_equiv(conduit.gauge)} mm\u00b2)" + + # fmt: off + rows = [[f'{html_bgcolor(conduit.bgcolor_title)}{remove_links(conduit.name)}' + if conduit.show_name else None], + [pn_info_string(HEADER_PN, None, + remove_links(conduit.pn)) if not isinstance(conduit.pn, list) else None, + html_line_breaks(pn_info_string(HEADER_MPN, + conduit.manufacturer if not isinstance(conduit.manufacturer, list) else None, + conduit.mpn if not isinstance(conduit.mpn, list) else None)), + html_line_breaks(pn_info_string(HEADER_SPN, + conduit.supplier if not isinstance(conduit.supplier, list) else None, + conduit.spn if not isinstance(conduit.spn, list) else None))], + [html_line_breaks(conduit.type), + f'{conduit.gauge} {conduit.gauge_unit}{awg_fmt}' if conduit.gauge else None, + f'{conduit.length} {conduit.length_unit}' if conduit.length > 0 else None, + translate_color(conduit.color, self.options.color_mode) if conduit.color else None, + html_colorbar(cable.color)], + '', + [html_image(conduit.image)], + [html_caption(conduit.image)]] + # fmt: on + + rows.extend(get_additional_component_table(self, conduit)) + rows.append([html_line_breaks(conduit.notes)]) + html.extend(nested_html_table(rows, html_bgcolor_attr(conduit.bgcolor))) + + wirehtml = [] + # conductor table + wirehtml.append('') + wirehtml.append(" ") + + for i in range(1, conduit.ports + 1): + # fmt: off + bgcolors = ['#000000'] + get_color_hex(conduit.colors[i - 1], pad=pad) + ['#000000'] + wirehtml.append(f" ") + wirehtml.append(f' ") + wirehtml.append(" ") + # fmt: on + + wirehtml.append(" ") + + wirehtml.append("
 
') + wirehtml.append(' ') + for j, bgcolor in enumerate(bgcolors[::-1]): # Reverse to match the curved wires when more than 2 colors + wirehtml.append(f' ') + wirehtml.append("
") + wirehtml.append("
 
") + + html = [ + row.replace("", "\n".join(wirehtml)) for row in html + ] + + html = "\n".join(html) + dot.node( + conduit.name, + label=f"<\n{html}\n>", + shape="box", + style="dotted", + fillcolor=translate_color(self.options.bgcolor_conduit, "HEX"), + ) + # mates for mate in self.mates: if mate.shape[-1] == ">": diff --git a/src/wireviz/wireviz.py b/src/wireviz/wireviz.py index 391b1ac..d0d029b 100755 --- a/src/wireviz/wireviz.py +++ b/src/wireviz/wireviz.py @@ -109,7 +109,10 @@ def parse( # containers for parsed component data and connection sets template_connectors = {} template_cables = {} + template_conduits = {} + template_conduit_connectors = {} connection_sets = [] + conduit_connection_sets = [] # actual harness harness = Harness( metadata=Metadata(**yaml_data.get("metadata", {})), @@ -129,8 +132,15 @@ def parse( # add items # parse YAML input file ==================================================== - sections = ["connectors", "cables", "connections"] - types = [dict, dict, list] + sections = [ + "connectors", + "cables", + "conduits", + "conduit-connectors", + "connections", + "conduit-connections", + ] + types = [dict, dict, dict, dict, list, list] for sec, ty in zip(sections, types): if sec in yaml_data and type(yaml_data[sec]) == ty: # section exists if len(yaml_data[sec]) > 0: # section has contents @@ -149,6 +159,10 @@ def parse( template_connectors[key] = attribs elif sec == "cables": template_cables[key] = attribs + elif sec == "conduits": + template_conduits[key] = attribs + elif sec == "conduit-connectors": + template_conduit_connectors[key] = attribs else: # section exists but is empty pass else: # section does not exist, create empty section @@ -158,6 +172,8 @@ def parse( yaml_data[sec] = [] connection_sets = yaml_data["connections"] + conduit_connection_sets = yaml_data.get("conduit-connections", []) + conduit_dict = {} # go through connection sets, generate and connect components ============== @@ -192,6 +208,7 @@ def parse( # utilities to check for alternating connectors and cables/arrows ========== alternating_types = ["connector", "cable/arrow"] + alternating_types_conduit = ["conduit-connector", "conduit"] expected_type = None def check_type(designator, template, actual_type): @@ -204,7 +221,7 @@ def parse( f'Expected {expected_type}, but "{designator}" ("{template}") is {actual_type}' ) - def alternate_type(): # flip between connector and cable/arrow + def alternate_type(alternating_types): # flip between types nonlocal expected_type expected_type = alternating_types[1 - alternating_types.index(expected_type)] @@ -307,7 +324,9 @@ def parse( f"{template} is an unknown template/designator/arrow." ) - alternate_type() # entries in connection set must alternate between connectors and cables/arrows + alternate_type( + alternating_types + ) # entries in connection set must alternate between connectors and cables/arrows # transpose connection set list # before: one item per component, one subitem per connection in set @@ -336,7 +355,7 @@ def parse( entry[index_item + 1] ) harness.connect( - from_name, from_pin, via_name, via_pin, to_name, to_pin + from_name, from_pin, [], via_name, via_pin, to_name, to_pin ) elif is_arrow(designator): @@ -362,10 +381,172 @@ def parse( # mate two connectors as a whole harness.add_mate_component(from_name, to_name, designator) + # go through conduit connection sets, generate and connect conduit components ============== + + for connection_set in conduit_connection_sets: + # figure out number of parallel connections within this set + connectioncount = [] + for entry in connection_set: + if isinstance(entry, list): + connectioncount.append(len(entry)) + elif isinstance(entry, dict): + connectioncount.append(len(expand(list(entry.values())[0]))) + # e.g.: - X1: [1-4,6] yields 5 + else: + pass # strings do not reveal connectioncount + if not any(connectioncount): + # no item in the list revealed connection count; + # assume connection count is 1 + connectioncount = [1] + # Example: The following is a valid connection set, + # even though no item reveals the connection count; + # the count is not needed because only a component-level mate happens. + # - + # - CONNECTOR + # - ==> + # - CONNECTOR + + # check that all entries are the same length + if len(set(connectioncount)) > 1: + raise Exception( + "All items in connection set must reference the same number of connections" + ) + # all entries are the same length, connection count is set + connectioncount = connectioncount[0] + + # expand string entries to list entries of correct length + for index, entry in enumerate(connection_set): + if isinstance(entry, str): + connection_set[index] = [entry] * connectioncount + + # resolve all designators + for index, entry in enumerate(connection_set): + if isinstance(entry, list): + for subindex, item in enumerate(entry): + template, designator = resolve_designator( + item, template_separator_char + ) + connection_set[index][subindex] = designator + elif isinstance(entry, dict): + key = list(entry.keys())[0] + template, designator = resolve_designator(key, template_separator_char) + value = entry[key] + connection_set[index] = {designator: value} + else: + pass # string entries have been expanded in previous step + + # expand all pin lists + for index, entry in enumerate(connection_set): + if isinstance(entry, list): + connection_set[index] = [{designator: 1} for designator in entry] + elif isinstance(entry, dict): + designator = list(entry.keys())[0] + pinlist = expand(entry[designator]) + connection_set[index] = [{designator: pin} for pin in pinlist] + else: + pass # string entries have been expanded in previous step + + # Populate wiring harness ============================================== + + expected_type = None # reset check for alternating types + # at the beginning of every connection set + # since each set may begin with either type + + # generate components + for entry in connection_set: + for item in entry: + designator = list(item.keys())[0] + template = designators_and_templates[designator] + + if ( + designator in harness.conduit_connectors + ): # existing conduit connector instance + check_type(designator, template, "conduit-connector") + elif template in template_conduit_connectors.keys(): + # generate new conduit connector instance from template + check_type(designator, template, "conduit-connector") + harness.add_conduit_connector( + name=designator, **template_conduit_connectors[template] + ) + + elif designator in harness.conduits: # existing conduit instance + check_type(designator, template, "conduit") + elif template in template_conduits.keys(): + # generate new conduit instance from template + check_type(designator, template, "conduit") + harness.add_conduit(name=designator, **template_conduits[template]) + + else: + raise Exception( + f"{template} is an unknown conduit template/designator." + ) + + alternate_type( + alternating_types_conduit + ) # entries in connection set must alternate between conduit-connectors and conduits + + # transpose connection set list + # before: one item per component, one subitem per connection in set + # after: one item per connection in set, one subitem per component + connection_set = list(map(list, zip(*connection_set))) + + # connect conduit components + for index_entry, entry in enumerate(connection_set): + for index_item, item in enumerate(entry): + designator = list(item.keys())[0] + + if designator in harness.conduits: + if index_item == 0: + # list started with a conduit, no conduit connector to join on left side + from_name, from_pin = (None, None) + else: + from_name, from_pin = get_single_key_and_value( + entry[index_item - 1] + ) + via_name, via_pin = (designator, item[designator]) + if index_item == len(entry) - 1: + # list ends with a conduit, no conduit connector to join on right side + to_name, to_pin = (None, None) + else: + to_name, to_pin = get_single_key_and_value( + entry[index_item + 1] + ) + harness.connect( + from_name, from_pin, [], via_name, via_pin, to_name, to_pin + ) + + # build conduit_dict + for conduit_connection_set in conduit_connection_sets: + for connection in conduit_connection_set: + if len(connection) == 3: + from_name = connection[0] + via_name = connection[1] + to_name = connection[2] + if via_name in harness.conduits: + conduit = via_name + conduit_connectors = [from_name, to_name] + for cable_name, cable in harness.cables.items(): + for conn in cable.connections: + if ( + conn.from_name in conduit_connectors + or conn.to_name in conduit_connectors + ): + if cable_name not in conduit_dict: + conduit_dict[cable_name] = [] + if conduit not in conduit_dict[cable_name]: + conduit_dict[cable_name].append(conduit) + + # set conduits for cables + for cable_name, cable in harness.cables.items(): + cable.conduits = conduit_dict.get(cable_name, []) + # warn about unused templates - proposed_components = list(template_connectors.keys()) + list( - template_cables.keys() + proposed_components = ( + list(template_connectors.keys()) + + list(template_cables.keys()) + + list(template_conduits.keys()) + + list(template_conduit_connectors.keys()) ) used_components = set(designators_and_templates.values()) forgotten_components = [c for c in proposed_components if not c in used_components] diff --git a/src/wireviz/wv_bom.py b/src/wireviz/wv_bom.py index 7eecfdf..2224a84 100644 --- a/src/wireviz/wv_bom.py +++ b/src/wireviz/wv_bom.py @@ -185,6 +185,56 @@ def generate_bom(harness: "Harness") -> List[BOMEntry]: # add cable/bundles aditional components to bom bom_entries.extend(get_additional_component_bom(cable)) + # conduits + for conduit in harness.conduits.values(): + if not conduit.ignore_in_bom: + description = ( + "Conduit" + + (f", {conduit.type}" if conduit.type else "") + + (f", {conduit.gauge} {conduit.gauge_unit}" if conduit.gauge else "") + + ( + f", {conduit.length} {conduit.length_unit}" + if conduit.length > 0 + else "" + ) + + ( + f", {translate_color(conduit.color, harness.options.color_mode)}" + if conduit.color + else "" + ) + ) + bom_entries.append( + { + "description": description, + "qty": conduit.length, + "unit": conduit.length_unit, + "designators": conduit.name if conduit.show_name else None, + **optional_fields(conduit), + } + ) + + # add conduits aditional components to bom + bom_entries.extend(get_additional_component_bom(conduit)) + + # conduit connectors + for conduit_connector in harness.conduit_connectors.values(): + if not conduit_connector.ignore_in_bom: + description = "Conduit Connector" + ( + f", {conduit_connector.type}" if conduit_connector.type else "" + ) + bom_entries.append( + { + "description": description, + "designators": ( + conduit_connector.name if conduit_connector.show_name else None + ), + **optional_fields(conduit_connector), + } + ) + + # add conduit connectors aditional components to bom + bom_entries.extend(get_additional_component_bom(conduit_connector)) + # add harness aditional components to bom directly, as they both are List[BOMEntry] bom_entries.extend(harness.additional_bom_items) @@ -204,9 +254,11 @@ def generate_bom(harness: "Harness") -> List[BOMEntry]: bom.append( { **group_entries[0], - "qty": int(total_qty) - if float(total_qty).is_integer() - else round(total_qty, 3), + "qty": ( + int(total_qty) + if float(total_qty).is_integer() + else round(total_qty, 3) + ), "designators": sorted(set(designators)), } ) diff --git a/src/wireviz/wv_html.py b/src/wireviz/wv_html.py index 4230110..d928446 100644 --- a/src/wireviz/wv_html.py +++ b/src/wireviz/wv_html.py @@ -111,9 +111,9 @@ def generate_html_output( if isinstance(entry, Dict): replacements[f""] = str(category) for entry_key, entry_value in entry.items(): - replacements[ - f"" - ] = html_line_breaks(str(entry_value)) + replacements[f""] = ( + html_line_breaks(str(entry_value)) + ) elif isinstance(entry, (str, int, float)): pass # TODO?: replacements[f""] = html_line_breaks(str(entry))