Source code for jitxlib.voltage_divider.solver

from dataclasses import dataclass
from logging import getLogger
from typing import Any, List, Optional, Tuple, cast

from jitx.toleranced import Toleranced
from jitxlib.parts import search_resistors, ExistKeys, DistinctKey, ResistorQuery
from jitxlib.parts.query_api import to_component

from .constraints import VoltageDividerConstraints
from .errors import (
    NoPrecisionSatisfiesConstraintsError,
    VinRangeTooLargeError,
    IncompatibleVinVoutError,
    NoSolutionFoundError,
)


logger = getLogger(__name__)


@dataclass(frozen=True)
class _ResistorData:
    """Resistor parameters the solver needs from a parts-database entry."""

    resistance: float
    mpn: str
    tolerance: Optional[Tuple[float, float]]  # (min, max)
    tcr: Optional[Tuple[float, float]]  # (pos, neg)


[docs] @dataclass class ResistorSelection: """A resistor chosen by the solver. Carries a resolved ``ResistorQuery`` pinned to the exact part (by MPN, with resistance and precision) so the circuit can instantiate it through the public ``jitxlib.parts.Resistor`` factory. """ resistance: float mpn: str query: ResistorQuery
[docs] @dataclass class VoltageDividerSolution: """ Voltage Divider Solution Type """ R_h: ResistorSelection R_l: ResistorSelection vo: Toleranced
[docs] @dataclass class Ratio: high: float low: float loss: float
[docs] def solve(constraints: VoltageDividerConstraints) -> VoltageDividerSolution: """ Solve the Voltage Divider Constraint Problem. """ search_prec = constraints.search_range goals = constraints.compute_initial_guess() for g in goals: if g < 0.0: raise IncompatibleVinVoutError(constraints.v_in, constraints.v_out) goal_r_hi, goal_r_lo = goals # Screen the input voltage requirement with perfect resistors vin_screen = constraints.compute_objective( Toleranced.exact(goal_r_hi), Toleranced.exact(goal_r_lo) ) if not constraints.is_compliant(vin_screen): raise VinRangeTooLargeError(goals, vin_screen) # Pre-screen precision series pre_screen = [] for std_prec in constraints.prec_series: vo = constraints.compute_objective( Toleranced.percent(goal_r_hi, std_prec), Toleranced.percent(goal_r_lo, std_prec), ) pre_screen.append((constraints.is_compliant(vo), std_prec, vo)) first_valid_series = next((i for i, elem in enumerate(pre_screen) if elem[0]), None) if first_valid_series is not None: series = constraints.prec_series[first_valid_series:] else: raise NoPrecisionSatisfiesConstraintsError(goals, pre_screen) # Try to solve for each valid precision for std_prec in series: logger.debug("Trying precision %s%%", std_prec) sol = solve_over_series(constraints, std_prec, search_prec) if sol is not None: return sol raise NoSolutionFoundError( "Failed to Source Resistors to Satisfy Voltage Divider Constraints" )
[docs] def solve_over_series( constraints: VoltageDividerConstraints, precision: float, search_prec: float ) -> Optional[VoltageDividerSolution]: goal_r_hi, goal_r_lo = constraints.compute_initial_guess() hi_res = query_resistance_by_values(constraints, goal_r_hi, precision, search_prec) lo_res = query_resistance_by_values(constraints, goal_r_lo, precision, search_prec) for ratio in sort_pairs_by_best_fit(constraints, precision, hi_res, lo_res): sol = filter_query_results(constraints, ratio, precision) if sol is not None: return sol return None
[docs] def filter_query_results( constraints: VoltageDividerConstraints, ratio: Ratio, precision: float ) -> Optional[VoltageDividerSolution]: logger.debug("Querying resistors for R-h=%s ohm R-l=%s ohm", ratio.high, ratio.low) r_his = query_resistors(constraints, ratio.high, precision) r_los = query_resistors(constraints, ratio.low, precision) min_srcs = constraints.min_sources if len(r_his) < min_srcs or len(r_los) < min_srcs: logger.debug( "Ignoring candidate: there must be at least %s resistors of each type", min_srcs, ) return None r_hi_cmp = r_his[0] r_lo_cmp = r_los[0] vo_set = study_solution(constraints, r_hi_cmp, r_lo_cmp, constraints.temp_range) vo_valids = [constraints.is_compliant(vo) for vo in vo_set] is_valid = all(vo_valids) if not is_valid: logger.debug("Ignoring candidate: not a solution when taking TCRs into account") def fmt(ok, vo): return "OK" if ok else f"FAIL ({vo} V)" logger.debug("min-temp: %s", fmt(vo_valids[0], vo_set[0])) logger.debug("max-temp: %s", fmt(vo_valids[1], vo_set[1])) return None # TODO: Compute the worst case v-out here and use that instead of just the first worst_case_vo = vo_set[0] mpn1 = r_hi_cmp.mpn mpn2 = r_lo_cmp.mpn vout_str = f"({vo_set[0]}, {vo_set[1]})V" if len(vo_set) > 1 else f"({vo_set[0]})V" try: current = vo_set[0].typ / ratio.low except Exception: current = "unknown" logger.info( "Solved: mpn1=%s, mpn2=%s, v-out=%s, current=%sA", mpn1, mpn2, vout_str, current, ) return VoltageDividerSolution( _select(r_hi_cmp, constraints.base_query, precision), _select(r_lo_cmp, constraints.base_query, precision), worst_case_vo, )
[docs] def sort_pairs_by_best_fit( constraints: VoltageDividerConstraints, precision: float, hi_res: List[float], lo_res: List[float], ) -> List[Ratio]: ratios = [] for rh in hi_res: for rl in lo_res: loss = constraints.compute_loss(rh, rl, precision) if loss is not None: ratios.append(Ratio(rh, rl, loss)) ratios.sort(key=lambda r: r.loss) return ratios
[docs] def query_resistance_by_values( constraints: VoltageDividerConstraints, goal_r: float, r_prec: float, min_prec: float, ) -> List[float]: """ Query for resistance values within the specified precision range using search_resistors. Returns a list of resistance values (float). """ def to_float(r: object) -> float: if not isinstance(r, int | float): raise ValueError( f"Expected returned resistance value from database to be an int|float, got {type(r)}: {r}" ) return float(r) # Use search_resistors with distinct resistance exist_keys = ExistKeys(["tcr_pos", "tcr_neg"]) distinct_key = DistinctKey("resistance") base_query = constraints.base_query resistances = search_resistors( base_query, resistance=Toleranced.percent(goal_r, min_prec), precision=r_prec / 100.0, exist=exist_keys, distinct=distinct_key, ) # Case from int to float (mimic stanza codebase, the database is sensitive to the difference, maybe due to caching). return [to_float(r) for r in resistances]
[docs] def query_resistors( constraints: VoltageDividerConstraints, target: float, prec: float ) -> List[_ResistorData]: """ Query for resistors matching a particular target resistance and precision. Returns the parameters the solver needs (resistance, mpn, tolerance, tcr). """ exist_keys = ExistKeys(["tcr_pos", "tcr_neg"]) base_query = constraints.base_query results = search_resistors( base_query, resistance=target, precision=prec / 100.0, exist=exist_keys, limit=constraints.min_sources, ) out: List[_ResistorData] = [] for r in results: # `to_component` (public, from query_api) parses a parts-db row into a # dataclass; read its fields through a cast so we depend only on the # public function and not on the private result type. c = cast(Any, to_component(r)) tol = c.tolerance tcr = c.tcr out.append( _ResistorData( resistance=float(c.resistance), mpn=str(c.mpn), tolerance=(tol.min, tol.max) if tol is not None else None, tcr=(tcr.pos, tcr.neg) if tcr is not None else None, ) ) return out
def _select( chosen: _ResistorData, base_query: ResistorQuery, precision: float ) -> ResistorSelection: """ Build a resolved query pinned to the exact validated part so the circuit instantiates the precise resistor whose TCR/tolerance the solver checked. """ query = base_query.update( mpn=chosen.mpn, resistance=chosen.resistance, precision=precision / 100.0 ) return ResistorSelection(chosen.resistance, chosen.mpn, query)
[docs] def study_solution( constraints: VoltageDividerConstraints, r_hi: _ResistorData, r_lo: _ResistorData, temp_range: Toleranced, ) -> List[Toleranced]: """ Compute the voltage divider expected output over a temperature range. Returns a list of Toleranced values for [min_temp, max_temp]. """ if r_lo.resistance == 0.0 and r_hi.resistance == 0.0: raise ValueError( f"Can't check output voltage current for a solution with two zero ohm resistors {r_lo.mpn} and {r_hi.mpn}." ) # Compute TCR deviations for min and max temperature lo_drs = [ compute_tcr_deviation(r_lo, temp_range.min_value), compute_tcr_deviation(r_lo, temp_range.max_value), ] hi_drs = [ compute_tcr_deviation(r_hi, temp_range.min_value), compute_tcr_deviation(r_hi, temp_range.max_value), ] r_lo_val = get_resistance(r_lo) r_hi_val = get_resistance(r_hi) results = [] for lo_dr, hi_dr in zip(lo_drs, hi_drs, strict=True): if lo_dr is not None and hi_dr is not None: vout = constraints.compute_objective(r_hi_val, r_lo_val, hi_dr, lo_dr) results.append(vout) else: raise ValueError("No TCR Data") return results
[docs] def get_resistance(r: _ResistorData) -> Toleranced: """ Get the resistance value as a Toleranced. Combines the nominal resistance with the (min, max) tolerance band. Raises an error if tolerance is None. """ if r.tolerance is None: raise ValueError( "Resistor tolerance must be specified (min, max). None is not allowed." ) return tol_minmax(r.resistance, r.tolerance)
[docs] def tol_minmax(typ: float, tolerance: Tuple[float, float]) -> Toleranced: """ Create a Toleranced value from a (min, max) tolerance band. Mirrors the Stanza implementation: tol(v, tolerance:MinMaxRange): coeff = min-max(1.0 + min(tolerance), 1.0 + max(tolerance)) v * coeff """ tol_min, tol_max = tolerance coeff = Toleranced.min_max(1.0 + tol_min, 1.0 + tol_max) return typ * coeff
[docs] def compute_tcr_deviation( resistor: _ResistorData, temperature: float ) -> Optional[Toleranced]: """ Compute the expected deviation window of a given resistor at a given temperature. This function mirrors the Stanza implementation in component-types.stanza: - Extracts tcr and reference temperature from the resistor. - Converts pos/neg to a Toleranced interval using Toleranced.min_max. - Calls compute_tcr_deviation_interval. - Returns None if tcr is not present. NOTE: This includes a workaround for known database issues with TCR values, as described in the Stanza code and PROD-328. """ tcr = resistor.tcr ref_temp = 25.0 # Default reference temperature if tcr is None: return None # This mirrors the Stanza hack for database issues: # See: https://linear.app/jitx/issue/PROD-328/tcr-values-in-database-seem-wrong p, n = tcr tcr_interval = Toleranced.min_max(min(p, n), max(p, n)) return compute_tcr_deviation_interval(tcr_interval, temperature, ref_temp)
[docs] def compute_tcr_deviation_interval( tcr: Toleranced, temperature: float, ref_temp: float = 25.0 ) -> Toleranced: """ Compute the expected deviation window of a given temperature coefficient. This function mirrors the Stanza implementation: - Returns 1.0 + (diff * tcr), where diff = temperature - ref_temp. - The result is a Toleranced window for the deviation (typically ~0.9 to 1.1). """ diff = temperature - ref_temp return 1.0 + (diff * tcr)