From 1b0f0229112483baa7f2442b0a9679af8c3422f2 Mon Sep 17 00:00:00 2001 From: Matthew Wildoer Date: Mon, 29 Apr 2024 20:30:15 -0700 Subject: [PATCH 1/2] Re-add table for assertions --- src/atopile/assertions.py | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/atopile/assertions.py b/src/atopile/assertions.py index 6989bfb5..3bfb37e8 100644 --- a/src/atopile/assertions.py +++ b/src/atopile/assertions.py @@ -54,11 +54,40 @@ class ErrorComputingAssertion(AssertionException): values.""" +class AssertionTable(Table): + def __init__(self) -> None: + super().__init__(show_header=True, header_style="bold green", title="Assertions") + + self.add_column("Status") + self.add_column("Assertion") + self.add_column("Numeric") + self.add_column("Address") + self.add_column("Notes") + + def add_row( + self, + status: str, + assertion_str: str, + numeric: str, + addr: address.AddrStr, + notes: str, + ): + super().add_row( + status, + assertion_str, + numeric, + address.get_instance_section(addr), + notes, + style=dark_row if len(self.rows) % 2 else light_row, + ) + + def generate_assertion_report(build_ctx: config.BuildContext): """ Generate a report based on assertions made in the source code. """ + table = AssertionTable() context = {} with errors.ExceptionAccumulator() as exception_accumulator: for instance_addr in instance_methods.all_descendants(build_ctx.entry): @@ -74,10 +103,23 @@ def generate_assertion_report(build_ctx: config.BuildContext): for symbol in new_symbols: context[symbol] = instance_methods.get_data(symbol) + assertion_str = parse_utils.reconstruct(assertion.src_ctx) + + instance_src = instance_addr + if instance.src_ctx: + instance_src += "\n (^ defined" + parse_utils.format_src_info(instance.src_ctx) + ")" + try: a = assertion.lhs(context) b = assertion.rhs(context) except errors.AtoError as e: + table.add_row( + "[red]ERROR[/]", + assertion_str, + "", + instance_src, + str(e), + ) raise ErrorComputingAssertion( f"Exception computing assertion: {str(e)}" ) from e @@ -88,6 +130,13 @@ def generate_assertion_report(build_ctx: config.BuildContext): a.pretty_str() + " " + assertion.operator + " " + b.pretty_str() ) if _do_op(a, assertion.operator, b): + table.add_row( + "[green]PASSED[/]", + assertion_str, + numeric, + instance_src, + "", + ) log.debug( textwrap.dedent(f""" Assertion [green]passed![/] @@ -98,6 +147,13 @@ def generate_assertion_report(build_ctx: config.BuildContext): extra={"markup": True} ) else: + table.add_row( + "[red]FAILED[/red]", + assertion_str, + numeric, + instance_src, + "", + ) raise AssertionFailed.from_ctx( assertion.src_ctx, textwrap.dedent(f""" @@ -108,6 +164,9 @@ def generate_assertion_report(build_ctx: config.BuildContext): addr=instance_addr, ) + # Dump the output to the console + rich.print(table) + def _do_op(a: RangedValue, op: str, b: RangedValue) -> bool: """Perform the operation specified by the operator.""" From 3a1206c51df3e3b8858e8fed63f90e2103425ba0 Mon Sep 17 00:00:00 2001 From: Matthew Wildoer Date: Mon, 29 Apr 2024 22:18:57 -0700 Subject: [PATCH 2/2] Maintain source formatting for as long as possible with ranged values, and make sensible formatting choices beyond there. --- src/atopile/expressions.py | 127 +++++++++++-------- src/atopile/front_end.py | 59 ++++----- tests/test_expressions/test_ranged_values.py | 14 ++ 3 files changed, 115 insertions(+), 85 deletions(-) diff --git a/src/atopile/expressions.py b/src/atopile/expressions.py index e3a77a31..35e51eaa 100644 --- a/src/atopile/expressions.py +++ b/src/atopile/expressions.py @@ -30,23 +30,47 @@ def _best_units(qty_a: pint.Quantity, qty_b: pint.Quantity) -> PlainUnit: return qty_b.units -_favourable_units = [ - pint.Unit("V"), - pint.Unit("ohm"), - pint.Unit("A"), - pint.Unit("W"), - pint.Unit("Hz"), - pint.Unit("F"), - pint.Unit("H"), -] +_multiplier_map = { + "femto": "f", + "pico": "p", + "nano": "n", + "micro": "u", + "milli": "m", + "": "", + "kilo": "k", + "mega": "M", + "giga": "G", + "tera": "T", +} -def _favourable_unit(unit: PlainUnit) -> PlainUnit: - """Return the most favourable unit for the given unit.""" - for fav_unit in _favourable_units: - if unit.is_compatible_with(fav_unit): - return fav_unit - return unit +_pretty_unit_map = { + "volt": "V", + "ohm": "Ω", + "ampere": "A", + "watt": "W", + "hertz": "Hz", + "farad": "F", + "henry": "H", + "second": "s", +} + + +pretty_unit_map = {lm + lu: sm + su for lm, sm in _multiplier_map.items() for lu, su in _pretty_unit_map.items()} +favorite_units_map = {pint.Unit(k).dimensionality: pint.Unit(k) for k in pretty_unit_map} + + +def pretty_unit(qty: pint.Quantity) -> tuple[float, str]: + """Return the most favorable magnitude and unit for the given quantity.""" + if qty.units.dimensionless: + return qty.magnitude, "" + + if qty.units.dimensionality in favorite_units_map: + qty = qty.to(favorite_units_map[qty.units.dimensionality]) + qty = qty.to_compact() + + units = str(qty.units) + return qty.magnitude, pretty_unit_map.get(units, units) class RangedValue: @@ -62,7 +86,7 @@ def __init__( val_a: Union[float, int, pint.Quantity], val_b: Optional[Union[float, int, pint.Quantity]] = None, unit: Optional[str | PlainUnit | pint.Unit] = None, - pretty_unit: Optional[str] = None, + str_rep: Optional[str] = None, ): # This is a bit of a hack, but simplifies upstream code marginally if val_b is None: @@ -95,54 +119,49 @@ def __init__( assert isinstance(val_b_mag, (float, int)) # Make the noise - self.pretty_unit = pretty_unit + self.str_rep = str_rep self.min_val = min(val_a_mag, val_b_mag) self.max_val = max(val_a_mag, val_b_mag) - @property - def best_usr_unit(self) -> str: - """Return a pretty string representation of the unit.""" - if self.pretty_unit: - return self.pretty_unit - return str(self.unit) - def to(self, unit: str | PlainUnit | pint.Unit) -> "RangedValue": """Return a new RangedValue in the given unit.""" return RangedValue(self.min_qty, self.max_qty, unit) - def to_compact(self) -> "RangedValue": - """Return a new RangedValue in the most compact unit.""" - return RangedValue( - # FIXME: still shit - self.min_qty.to_compact(), - self.max_qty.to_compact(), - ) - def pretty_str( - self, max_decimals: Optional[int] = 2, unit: Optional[pint.Unit] = None + self, + max_decimals: Optional[int] = 2, + format: Optional[str] = None, ) -> str: """Return a pretty string representation of the RangedValue.""" - if unit is not None: - val = self.to(unit) - else: - val = self.to(_favourable_unit(self.unit)).to_compact() - - if max_decimals is None: - nom = str(val.nominal) - if val.tolerance != 0: - nom += f" +/- {str(val.tolerance)}" - if val.tolerance_pct is not None: - nom += f" ({str(val.tolerance_pct)}%)" - else: - nom = _custom_float_format(val.nominal, max_decimals) - if val.tolerance != 0: - nom += f" +/- {_custom_float_format(val.tolerance, max_decimals)}" - if val.tolerance_pct is not None: - nom += ( - f" ({_custom_float_format(val.tolerance_pct, max_decimals)}%)" - ) - - return f"{nom} {val.best_usr_unit}" + def _f(val: float): + if max_decimals is None: + return str(val) + return _custom_float_format(val, max_decimals) + + if self.str_rep: + return self.str_rep + + # Single-ended + if self.tolerance_pct * 1e4 < pow(10, -max_decimals): + nom, unit = pretty_unit(self.nominal * self.unit) + return f"{_f(nom)}{unit}" + + # Bound values + if self.tolerance_pct > 20 or format == "bound": + min_val, min_unit = pretty_unit(self.min_qty) + max_val, max_unit = pretty_unit(self.max_qty) + + if min_unit == max_unit: + return f"{_f(min_val)} to {_f(max_val)} {min_unit}" + return f"{_f(min_val)}{min_unit} to {_f(max_val)}{max_unit}" + + # Bilateral values + nom, unit = pretty_unit(self.nominal * self.unit) + tol, tol_unit = pretty_unit(self.tolerance * self.unit) + + if unit == tol_unit: + return f"{_f(nom)} ± {_f(tol)} {unit}" + return f"{_f(nom)}{unit} ± {_f(tol)}{tol_unit}" def __str__(self) -> str: return self.pretty_str() diff --git a/src/atopile/front_end.py b/src/atopile/front_end.py index 7199cfe5..b2427648 100644 --- a/src/atopile/front_end.py +++ b/src/atopile/front_end.py @@ -9,7 +9,6 @@ from collections import defaultdict, deque from contextlib import ExitStack, contextmanager from itertools import chain -from numbers import Number from pathlib import Path from typing import Any, Callable, Iterable, Mapping, Optional @@ -17,9 +16,10 @@ from antlr4 import ParserRuleContext from attrs import define, field, resolve_types -from atopile import address, config, errors, expressions +from atopile import address, config, errors, expressions, parse_utils from atopile.address import AddrStr from atopile.datatypes import KeyOptItem, KeyOptMap, Ref, StackList +from atopile.expressions import RangedValue from atopile.generic_methods import recurse from atopile.parse import parser from atopile.parse_utils import get_src_info_from_ctx @@ -77,19 +77,6 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__} {self.address}>" -class RangedValue(expressions.RangedValue): - def __init__( - self, - val_a: Number | pint.Quantity, - val_b: Number | pint.Quantity, - unit: Optional[pint.Unit] = None, - src_ctx: Optional[ParserRuleContext] = None, - pretty_unit: Optional[str] = None, - ): - self.src_ctx = src_ctx - super().__init__(val_a, val_b, unit, pretty_unit) - - class Expression(expressions.Expression): def __init__( self, @@ -402,12 +389,16 @@ def visitQuantity(self, ctx: ap.QuantityContext) -> RangedValue: else: unit = pint.Unit("") - return RangedValue( - src_ctx=ctx, + value = RangedValue( val_a=value, val_b=value, unit=unit, + str_rep=parse_utils.reconstruct(ctx), + # We don't bother with other formatting info here + # because it's not used for un-toleranced values ) + setattr(value, "src_ctx", ctx) + return value def visitBilateral_quantity(self, ctx: ap.Bilateral_quantityContext) -> RangedValue: """Yield a physical value from a bilateral quantity context.""" @@ -432,12 +423,14 @@ def visitBilateral_quantity(self, ctx: ap.Bilateral_quantityContext) -> RangedVa ) # In this case, life's a little easier, and we can simply multiply the nominal - return RangedValue( - src_ctx=ctx, + value = RangedValue( val_a=nominal_quantity.min_val - (nominal_quantity.min_val * tol_num / tol_divider), val_b=nominal_quantity.max_val + (nominal_quantity.max_val * tol_num / tol_divider), unit=nominal_quantity.unit, + str_rep=parse_utils.reconstruct(ctx), ) + setattr(value, "src_ctx", ctx) + return value # Handle tolerances with units if tol_ctx.name(): @@ -447,12 +440,14 @@ def visitBilateral_quantity(self, ctx: ap.Bilateral_quantityContext) -> RangedVa # If the nominal has no unit, then we take the unit's tolerance for the nominal if nominal_quantity.unit == pint.Unit(""): - return RangedValue( - src_ctx=ctx, + value = RangedValue( val_a=nominal_quantity.min_val - tol_quantity.min_val, val_b=nominal_quantity.max_val + tol_quantity.max_val, unit=tol_quantity.unit, + str_rep=parse_utils.reconstruct(ctx), ) + setattr(value, "src_ctx", ctx) + return value # If the nominal has a unit, then we rely on the ranged value's unit compatibility try: @@ -466,12 +461,14 @@ def visitBilateral_quantity(self, ctx: ap.Bilateral_quantityContext) -> RangedVa # If there's no unit or percent, then we have a simple tolerance in the same units # as the nominal - return RangedValue( - src_ctx=ctx, + value = RangedValue( val_a=nominal_quantity.min_val - tol_num, val_b=nominal_quantity.max_val + tol_num, unit=nominal_quantity.unit, + str_rep=parse_utils.reconstruct(ctx), ) + setattr(value, "src_ctx", ctx) + return value def visitBound_quantity(self, ctx: ap.Bound_quantityContext) -> RangedValue: """Yield a physical value from a bound quantity context.""" @@ -485,28 +482,28 @@ def visitBound_quantity(self, ctx: ap.Bound_quantityContext) -> RangedValue: if (start.unit == pint.Unit("")) ^ (end.unit == pint.Unit("")): if start.unit == pint.Unit(""): known_unit = end.unit - known_pretty_unit = end.pretty_unit else: known_unit = start.unit - known_pretty_unit = start.pretty_unit - return RangedValue( - src_ctx=ctx, + value = RangedValue( val_a=start.min_val, val_b=end.min_val, unit=known_unit, - pretty_unit=known_pretty_unit, + str_rep=parse_utils.reconstruct(ctx), ) + setattr(value, "src_ctx", ctx) + return value # If they've both got units, let the RangedValue handle # the dimensional compatibility try: - return RangedValue( - src_ctx=ctx, + value = RangedValue( val_a=start.min_qty, val_b=end.min_qty, - pretty_unit=start.pretty_unit, + str_rep=parse_utils.reconstruct(ctx), ) + setattr(value, "src_ctx", ctx) + return value except pint.DimensionalityError as ex: raise errors.AtoTypeError.from_ctx( ctx, diff --git a/tests/test_expressions/test_ranged_values.py b/tests/test_expressions/test_ranged_values.py index 46b491ec..35c767a1 100644 --- a/tests/test_expressions/test_ranged_values.py +++ b/tests/test_expressions/test_ranged_values.py @@ -40,3 +40,17 @@ def test_arithmetic(): assert 3 - RangedValue(1, 2) == RangedValue(-2, -1) assert 4 * RangedValue(1, 2) == RangedValue(4, 8) assert 5 / RangedValue(1, 2) == RangedValue(5 / 2, 5) + + +def test_pretty_str(): + str_rep = "mhmm, this isn't a ranged value" + assert RangedValue(3, 3, pint.Unit("V"), str_rep=str_rep).pretty_str() == str_rep + assert RangedValue(3, 3, pint.Unit("V")).pretty_str() == "3V" + assert RangedValue(3, 5, pint.Unit("V")).pretty_str() == "3 to 5 V" + assert RangedValue(3, 3.1, pint.Unit("V")).pretty_str() == "3.05V ± 50mV" + + # Make sure combined units compact properly + v = RangedValue(3, 3, pint.Unit("V")) + r = RangedValue(1000, 1000, pint.Unit("Ω")) + i = v / r + assert i.pretty_str() == "3mA"