From 400c242c9056834862f4049b65fb0e4b3f4fbeb4 Mon Sep 17 00:00:00 2001 From: Daniel Rojas Date: Tue, 16 Apr 2024 14:51:28 +0200 Subject: [PATCH] Move `parse_number_and_unit()` and `NumberAndUnit` definition to `wv_utils.py` Remove unused attribute Remove unused `&&` in GitHub workflow Remove duplicate `category` attribute Removed from `Connector` class since it is already defined in the `Component` superclass. Remove unnecessary casting of `int` to `float` https://github.com/wireviz/WireViz/pull/251#discussion_r1359000766 Continue work on BOM handling (WIP) --- .github/workflows/main.yml | 40 ++++----- src/wireviz/wv_dataclasses.py | 158 +++++++++++++++++----------------- src/wireviz/wv_graphviz.py | 31 +++++-- src/wireviz/wv_harness.py | 46 ++++++---- src/wireviz/wv_utils.py | 33 +++++++ tests/bom/bomqty.yml | 52 +++++------ 6 files changed, 207 insertions(+), 153 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fe576b0..533b74f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,23 +10,23 @@ jobs: matrix: python-version: ["3.8", "3.10"] steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Setup Graphviz - uses: ts-graphviz/setup-graphviz@v1 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install . - - name: Create Examples - run: PYTHONPATH=$(pwd)/src:$PYTHONPATH && python src/wireviz/tools/build_examples.py - - name: Upload examples, demos, and tutorials - uses: actions/upload-artifact@v2 - with: - name: examples-and-tutorials - path: | - examples/ - tutorial/ + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Setup Graphviz + uses: ts-graphviz/setup-graphviz@v1 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install . + - name: Create Examples + run: PYTHONPATH=$(pwd)/src:$PYTHONPATH python src/wireviz/tools/build_examples.py + - name: Upload examples, demos, and tutorials + uses: actions/upload-artifact@v2 + with: + name: examples-and-tutorials + path: | + examples/ + tutorial/ diff --git a/src/wireviz/wv_dataclasses.py b/src/wireviz/wv_dataclasses.py index dc79daf..ded141a 100644 --- a/src/wireviz/wv_dataclasses.py +++ b/src/wireviz/wv_dataclasses.py @@ -20,7 +20,14 @@ from wireviz.wv_colors import ( SingleColor, get_color_by_colorcode_index, ) -from wireviz.wv_utils import aspect_ratio, awg_equiv, mm2_equiv, remove_links +from wireviz.wv_utils import ( + NumberAndUnit, + awg_equiv, + aspect_ratio, + mm2_equiv, + parse_number_and_unit, + remove_links, +) # Each type alias have their legal values described in comments # - validation might be implemented in the future @@ -54,7 +61,6 @@ 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_" @@ -184,7 +190,7 @@ class Component: supplier: str = None spn: str = None # BOM info - qty: NumberAndUnit = NumberAndUnit(1, None) + qty: Optional[Union[None, int, float]] = None amount: Optional[NumberAndUnit] = None sum_amounts_in_bom: bool = True ignore_in_bom: bool = False @@ -195,70 +201,31 @@ class Component: partnos = [remove_links(entry) for entry in partnos] partnos = tuple(partnos) self.partnumbers = PartNumberInfo(*partnos) - - self.qty = self.parse_number_and_unit(self.qty, None) - self.amount = self.parse_number_and_unit(self.amount, None) - - def parse_number_and_unit( - self, - inp: Optional[Union[NumberAndUnit, float, int, str]], - default_unit: Optional[str] = None, - ) -> Optional[NumberAndUnit]: - if inp is None: - return None - elif isinstance(inp, NumberAndUnit): - return inp - elif isinstance(inp, float) or isinstance(inp, int): - return NumberAndUnit(float(inp), default_unit) - elif isinstance(inp, str): - if " " in inp: - number, unit = inp.split(" ", 1) - else: - number, unit = inp, default_unit - try: - number = float(number) - except ValueError: - raise Exception( - f"{inp} is not a valid number and unit.\n" - "It must be a number, or a number and unit separated by a space." - ) - else: - return NumberAndUnit(number, unit) + self.amount = parse_number_and_unit(self.amount, None) @property def bom_hash(self) -> BomHash: + if isinstance(self, AdditionalComponent): + _amount = self.amount_computed + else: + _amount = self.amount + if self.sum_amounts_in_bom: _hash = BomHash( description=self.description, - qty_unit=self.amount.unit if self.amount else None, + qty_unit=_amount.unit if _amount else None, amount=None, partnumbers=self.partnumbers, ) else: _hash = BomHash( description=self.description, - qty_unit=self.qty.unit, - amount=self.amount, + qty_unit=None, + amount=_amount, partnumbers=self.partnumbers, ) return _hash - @property - def bom_qty(self) -> float: - if self.sum_amounts_in_bom: - if self.amount: - return self.qty.number * self.amount.number - else: - return self.qty.number - else: - return self.qty.number - - def bom_amount(self) -> NumberAndUnit: - if self.sum_amounts_in_bom: - return NumberAndUnit(None, None) - else: - return self.amount - @property def has_pn_info(self) -> bool: return any([self.pn, self.manufacturer, self.mpn, self.supplier, self.spn]) @@ -292,7 +259,9 @@ class GraphicalComponent(Component): # abstract class @dataclass class AdditionalComponent(GraphicalComponent): qty_multiplier: Union[QtyMultiplierConnector, QtyMultiplierCable, int] = 1 - _qty_multiplier_computed: Union[int, float] = 1 + qty_computed: Optional[int] = None + explicit_qty: bool = True + amount_computed: Optional[NumberAndUnit] = None note: str = None def __post_init__(self): @@ -311,9 +280,13 @@ class AdditionalComponent(GraphicalComponent): else: raise Exception(f"Unknown qty multiplier: {self.qty_multiplier}") - @property - def bom_qty(self): - return self.qty.number * self._qty_multiplier_computed + if self.qty is None and self.qty_multiplier in [ + QtyMultiplierCable.TOTAL_LENGTH, + QtyMultiplierCable.LENGTH, + 1, + ]: # simplify add.comp. table in parent node for implicit qty 1 + self.qty = 1 + self.explicit_qty = False @dataclass @@ -325,8 +298,6 @@ class TopLevelGraphicalComponent(GraphicalComponent): # abstract class additional_parameters: Optional[Dict] = None additional_components: List[AdditionalComponent] = field(default_factory=list) notes: Optional[MultilineHypertext] = None - # BOM options - add_up_in_bom: Optional[bool] = None # rendering options bgcolor_title: Optional[SingleColor] = None show_name: Optional[bool] = None @@ -336,7 +307,6 @@ class TopLevelGraphicalComponent(GraphicalComponent): # abstract class class Connector(TopLevelGraphicalComponent): # connector-specific properties style: Optional[str] = None - category: Optional[str] = None loops: List[List[Pin]] = field(default_factory=list) # pin information in particular pincount: Optional[int] = None @@ -380,13 +350,12 @@ class Connector(TopLevelGraphicalComponent): self.color = MultiColor(self.color) # connectors do not support custom qty or amount - if self.qty != NumberAndUnit(1, None): + if self.qty is None: + self.qty = 1 + if self.qty != 1: raise Exception("Connector qty != 1 not supported") if self.amount is not None: raise Exception("Connector amount not supported") - # TODO: Delete next two assignments if tests above is sufficient. Please verify! - self.qty = NumberAndUnit(1, None) - self.amount = None if isinstance(self.image, dict): self.image = Image(**self.image) @@ -451,7 +420,9 @@ class Connector(TopLevelGraphicalComponent): raise Exception("Loops must be between exactly two pins!") for pin in loop: if pin not in self.pins: - raise Exception(f'Unknown loop pin "{pin}" for connector "{self.name}"!') + raise Exception( + f'Unknown loop pin "{pin}" for connector "{self.name}"!' + ) # Make sure loop connected pins are not hidden. # side=None, determine side to show loops during rendering self.activate_pin(pin, side=None, is_connection=True) @@ -488,7 +459,12 @@ class Connector(TopLevelGraphicalComponent): raise Exception("Used a cable multiplier in a connector!") else: # int or float computed_factor = subitem.qty_multiplier - subitem._qty_multiplier_computed = computed_factor + + if subitem.qty is not None: + subitem.qty_computed = subitem.qty * computed_factor + else: + subitem.qty_computed = computed_factor + subitem.amount_computed = subitem.amount @dataclass @@ -623,14 +599,17 @@ class Cable(TopLevelGraphicalComponent): @property def bom_hash(self): if self.category == "bundle": - raise Exception("Do this at the wire level!") # TODO + # This line should never be reached, since caller checks + # whether item is a bundle and if so, calls bom_hash + # for each individual wire instead + raise Exception("Do this at the wire level!") else: return super().bom_hash @property def description(self) -> str: if self.category == "bundle": - raise Exception("Do this at the wire level!") # TODO + raise Exception("Do this at the wire level!") else: substrs = [ ("", "Cable"), @@ -668,6 +647,12 @@ class Cable(TopLevelGraphicalComponent): self.bgcolor_title = SingleColor(self.bgcolor_title) self.color = MultiColor(self.color) + # cables do not support custom qty or amount + if self.qty is None: + self.qty = 1 + if self.qty != 1: + raise Exception("Cable qty != 1 not supported") + if isinstance(self.image, dict): self.image = Image(**self.image) @@ -675,8 +660,8 @@ class Cable(TopLevelGraphicalComponent): # allow gauge, length, and other fields to be lists too (like part numbers), # and assign them the same way to bundles. - self.gauge = self.parse_number_and_unit(self.gauge, "mm2") - self.length = self.parse_number_and_unit(self.length, "m") + self.gauge = parse_number_and_unit(self.gauge, "mm2") + self.length = parse_number_and_unit(self.length, "m") self.amount = self.length # for BOM if self.wirecount: # number of wires explicitly defined @@ -753,9 +738,11 @@ class Cable(TopLevelGraphicalComponent): index=index_offset, id=id, label="Shield", - color=MultiColor(self.shield) - if isinstance(self.shield, str) - else MultiColor(None), + color=( + MultiColor(self.shield) + if isinstance(self.shield, str) + else MultiColor(None) + ), parent=self.designator, ) @@ -789,27 +776,40 @@ class Cable(TopLevelGraphicalComponent): ) qty_multipliers_computed = { "WIRECOUNT": len(self.wire_objects), - "TERMINATIONS": 999, # TODO + # "TERMINATIONS": ___, # 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 in ["LENGTH", "TOTAL_LENGTH"]: - if subitem.qty.unit is not None: + # since length can have a unit, use amount fields to hold + if subitem.amount is not None: raise Exception( - f"No unit may be specified when using" - f"{subitem.qty_multiplier} as a multiplier" + f"No amount may be specified when using " + f"{subitem.qty_multiplier.name} as a multiplier." ) - subitem.qty = NumberAndUnit(subitem.qty.number, self.length.unit) + subitem.qty_computed = subitem.qty if subitem.qty else 1 + subitem.amount_computed = NumberAndUnit( + computed_factor, self.length.unit + ) + else: + # multiplier unrelated to length, therefore no unit + if subitem.qty is not None: + subitem.qty_computed = subitem.qty * computed_factor + else: + subitem.qty_computed = computed_factor + subitem.amount_computed = subitem.amount 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 + if subitem.qty is not None: + subitem.qty_computed = subitem.qty * subitem.qty_multiplier + else: + subitem.qty_computed = subitem.qty_multiplier + subitem.amount_computed = subitem.amount @dataclass diff --git a/src/wireviz/wv_graphviz.py b/src/wireviz/wv_graphviz.py index 7f5cad6..d42dcac 100644 --- a/src/wireviz/wv_graphviz.py +++ b/src/wireviz/wv_graphviz.py @@ -114,11 +114,28 @@ def gv_additional_component_table(component): rows = [] for subitem in component.additional_components: + + if subitem.explicit_qty: + text_qty, unit_qty = subitem.qty_computed, "x" + if subitem.amount_computed is not None: + text_desc = f"{subitem.amount_computed.number} {subitem.amount_computed.unit} {subitem.description}" + else: + text_desc = f"{subitem.description}" + else: + if subitem.amount_computed is not None: + text_qty, unit_qty = ( + subitem.amount_computed.number, + subitem.amount_computed.unit, + ) + else: + text_qty, unit_qty = "1", "x" + text_desc = subitem.description + firstline = [ Td(bom_bubble(subitem.bom_id)), - Td(f"{subitem.bom_qty}", align="right"), - Td(f"{subitem.qty.unit if subitem.qty.unit else 'x'}", align="left"), - Td(f"{subitem.description}", align="left"), + Td(text_qty, align="right"), + Td(unit_qty, align="left"), + Td(text_desc, align="left"), Td(f"{subitem.note if subitem.note else ''}", align="left"), ] rows.append(Tr(firstline)) @@ -584,7 +601,9 @@ def apply_dot_tweaks(dot, tweak): if n_subs < 1: warnings.warn(f"tweak: {attr} not found in {keyword}!") elif n_subs > 1: - warnings.warn(f"tweak: {attr} removed {n_subs} times in {keyword}!") + warnings.warn( + f"tweak: {attr} removed {n_subs} times in {keyword}!" + ) continue if len(value) == 0 or " " in value: @@ -597,7 +616,9 @@ def apply_dot_tweaks(dot, tweak): # If attr not found, then append it entry = re.sub(r"\]$", f" {attr}={value}]", entry) elif n_subs > 1: - warnings.warn(f"tweak: {attr} overridden {n_subs} times in {keyword}!") + warnings.warn( + f"tweak: {attr} overridden {n_subs} times in {keyword}!" + ) dot.body[i] = entry diff --git a/src/wireviz/wv_harness.py b/src/wireviz/wv_harness.py index fa6a01f..0c730ac 100644 --- a/src/wireviz/wv_harness.py +++ b/src/wireviz/wv_harness.py @@ -86,7 +86,7 @@ class Harness: arrow = Arrow(direction=parse_arrow_str(arrow_str), weight=ArrowWeight.SINGLE) self.mates.append(MateComponent(from_name, to_name, arrow)) - def populate_bom(self): + def populate_bom(self): # called once harness creation is complete # helper lists all_toplevel_items = ( list(self.connectors.values()) @@ -131,12 +131,10 @@ class Harness: if item.ignore_in_bom: continue if not item.bom_hash in self.bom: - print(f"{item}'s hash' not found in BOM dict.") + print(f"{item}'s hash' not found in BOM dict.") # Should not happen continue item.bom_id = self.bom[item.bom_hash]["id"] - # print_bom_table(self.bom) # for debugging - def _add_to_internal_bom(self, item: Component): if item.ignore_in_bom: return @@ -173,38 +171,50 @@ class Harness: cat = "" if item.category == "bundle": + # wires of a bundle are added as individual BOM entries for subitem in item.wire_objects.values(): _add( hash=subitem.bom_hash, - qty=item.bom_qty, # should be 1 + qty=item.qty, # should be 1 designator=item.designator, # inherit from parent item category=cat, ) else: _add( hash=item.bom_hash, - qty=item.bom_qty, + qty=item.qty, # should be 1 designator=item.designator, category=cat, ) + if item.additional_components: - if item.category == "bundle": - pass # TODO item.compute_qty_multipliers() - for comp in item.additional_components: - if comp.ignore_in_bom: - continue - _add( - hash=comp.bom_hash, - designator=item.designator, - qty=comp.bom_qty, - category=BomCategory.ADDITIONAL_INSIDE, - ) + + for comp in item.additional_components: + if comp.ignore_in_bom: + continue + + if comp.sum_amounts_in_bom: + if comp.amount_computed: + total_qty = comp.qty_computed * comp.amount_computed.number + else: + total_qty = comp.qty_computed + else: + total_qty = comp.qty_computed + _add( + hash=comp.bom_hash, + designator=item.designator, + qty=total_qty, + # no explicit qty specified; assume qty = 1 + # used to simplify add.comp. table within parent node + # e.g. show "10 mm Heatshrink" instead of "1x 10 mm Heatshrink" + category=BomCategory.ADDITIONAL_INSIDE, + ) elif isinstance(item, AdditionalBomItem): cat = BomCategory.ADDITIONAL_OUTSIDE _add( hash=item.bom_hash, - qty=item.bom_qty, + qty=item.qty, designator=None, category=cat, ) diff --git a/src/wireviz/wv_utils.py b/src/wireviz/wv_utils.py index 10b5068..fb04e88 100644 --- a/src/wireviz/wv_utils.py +++ b/src/wireviz/wv_utils.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- import re +from collections import namedtuple from pathlib import Path from typing import List, Optional, Union +NumberAndUnit = namedtuple("NumberAndUnit", "number unit") + awg_equiv_table = { "0.09": "28", "0.14": "26", @@ -76,6 +79,36 @@ def get_single_key_and_value(d: dict): return next(iter(d.items())) +def parse_number_and_unit( + inp: Optional[Union[NumberAndUnit, float, int, str]], + default_unit: Optional[str] = None, +) -> Optional[NumberAndUnit]: + if inp is None: + return None + elif isinstance(inp, NumberAndUnit): + return inp + elif isinstance(inp, float) or isinstance(inp, int): + return NumberAndUnit(inp, default_unit) + elif isinstance(inp, str): + if " " in inp: + num_str, unit = inp.split(" ", 1) + else: + num_str, unit = inp, default_unit + + try: + number = int(num_str) + except ValueError: # maybe it is a float? + try: + number = float(num_str) + except ValueError: # neither float nor int + 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." + ) + + return NumberAndUnit(number, unit) + + def int2tuple(inp): if isinstance(inp, tuple): output = inp diff --git a/tests/bom/bomqty.yml b/tests/bom/bomqty.yml index 9302139..0cb3e15 100644 --- a/tests/bom/bomqty.yml +++ b/tests/bom/bomqty.yml @@ -7,21 +7,16 @@ connectors: type: Contains additional components pincount: 6 additional_components: - - - type: One, no unit - - - type: Two kilometers - qty: 2 km - - - type: Takes pincount times seven + - type: One, no unit + - type: Two kilometers + amount: 2 km + - type: Takes pincount times seven qty: 7 qty_multiplier: pincount - - - type: Takes 10 mm per populated pin - qty: 10 mm + - type: Takes 10 mm per populated pin + amount: 10 mm qty_multiplier: populated - - - type: Takes number of connections + - type: Takes number of connections qty_multiplier: connections cables: @@ -31,22 +26,19 @@ cables: length: 1.5 color_code: DIN additional_components: - - - type: One - - - type: Three centimeters - qty: 3 cm - - - type: Takes wirecount times two + - type: One + - type: Three centimeters + amount: 3 cm + - type: Takes wirecount times two qty: 2 qty_multiplier: wirecount - - - type: Takes length times three - qty: 3 # adding unit here should cause error because the length already has a unit + - type: Takes length times three + qty: 3 + # adding amount here should cause error because the length already has a unit qty_multiplier: length - - - type: Takes total length times three - qty: 2 # adding unit here should cause error because the length already has a unit + - type: Takes total length times three + qty: 2 + # adding amount here should cause error because the length already has a unit qty_multiplier: total_length W2: @@ -55,11 +47,9 @@ cables: colors: [tomato, skyblue] connections: - - - - X1: [1-3] + - - X1: [1-3] - C1: [1-3] - X2: [1-3] - - - - X1: [3,4] - - W2: [1,2] - - X2: [3,4] + - - X1: [3, 4] + - W2: [1, 2] + - X2: [3, 4]