Add Conduit support for cable bundling

- Add Conduit and ConduitConnector classes in DataClasses.py
- Extend parsing in wireviz.py for conduits/conduit-connectors/conduit-connections
- Add rendering logic in Harness.py (dotted style, port colors)
- Update BOM in wv_bom.py
- Add example ex16.yml

Refs #485
This commit is contained in:
Joe7381 2025-11-25 16:20:06 +01:00
parent e4fe099f8c
commit bed9f43d04
6 changed files with 491 additions and 55 deletions

46
examples/ex16.yml Normal file
View File

@ -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

View File

@ -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]

View File

@ -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)],
'<!-- wire table -->',
[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('<table border="0" cellspacing="0" cellborder="0">')
wirehtml.append(" <tr><td>&nbsp;</td></tr>")
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" <tr>")
wirehtml.append(f' <td colspan="3" border="0" cellspacing="0" cellpadding="0" port="w{i}" height="{(2 * len(bgcolors))}">')
wirehtml.append(' <table cellspacing="0" cellborder="0" border="0">')
for j, bgcolor in enumerate(bgcolors[::-1]): # Reverse to match the curved wires when more than 2 colors
wirehtml.append(f' <tr><td colspan="3" cellpadding="0" height="2" bgcolor="{bgcolor if bgcolor != "" else wv_colors.default_color}" border="0"></td></tr>')
wirehtml.append(" </table>")
wirehtml.append(" </td>")
wirehtml.append(" </tr>")
# fmt: on
wirehtml.append(" <tr><td>&nbsp;</td></tr>")
wirehtml.append(" </table>")
html = [
row.replace("<!-- wire table -->", "\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] == ">":

View File

@ -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]

View File

@ -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)),
}
)

View File

@ -111,9 +111,9 @@ def generate_html_output(
if isinstance(entry, Dict):
replacements[f"<!-- %{item}_{index+1}% -->"] = str(category)
for entry_key, entry_value in entry.items():
replacements[
f"<!-- %{item}_{index+1}_{entry_key}% -->"
] = html_line_breaks(str(entry_value))
replacements[f"<!-- %{item}_{index+1}_{entry_key}% -->"] = (
html_line_breaks(str(entry_value))
)
elif isinstance(entry, (str, int, float)):
pass # TODO?: replacements[f"<!-- %{item}_{category}% -->"] = html_line_breaks(str(entry))