"""Electrical engineering calculations and component reference. Everything in this module except find_datasheet and search_papers is pure local computation. That is the point: an LLM doing IPC-2221 trace width in its head produces a plausible number, and a plausible number on a board that carries 5 A is a burned trace. These give an exact one, show the formula, and state the standard they come from. """ from __future__ import annotations import logging import math import re from difflib import SequenceMatcher from typing import Literal from urllib.parse import quote_plus from fastapi import APIRouter, Depends from pydantic import Field from .. import cache from ..httpc import get_client, get_json, get_local_client from ..security import require_auth from ..settings import settings from ..toolbase import NO_RESULTS, ToolModel, cite_line, tool from ..units import UnitError, eng, parse, require log = logging.getLogger(__name__) router = APIRouter(prefix="/tools", tags=["electronics"], dependencies=[Depends(require_auth)]) # Deliberately terse. This string is repeated across ~20 optional fields, and # every character of it is re-sent to the model on every iteration of the tool # loop. Spelled out in full it cost roughly 300 tokens per call for something # the docstring can say once. VALUE_HELP = "Leave empty if not applicable." # ------------------------------------------------------------ ohms_law_power class OhmsLawRequest(ToolModel): voltage: str | None = Field(default=None, description="Voltage in volts.") current: str | None = Field(default=None, description="Current in amps.") resistance: str | None = Field(default=None, description="Resistance in ohms.") power: str | None = Field(default=None, description="Power in watts.") @router.post("/ohms_law_power", operation_id="ohms_law_power", response_model=str, summary="Solve Ohm's law and power") @tool async def ohms_law_power(body: OhmsLawRequest) -> str: """Given any two of voltage, current, resistance and power, calculate the other two. Use it for every Ohm's law question rather than working it out yourself; it shows the formulas used. Provide exactly two values and leave the rest empty. Values accept plain numbers (4700), SI prefixes (4.7k, 100n, 2.2u) or engineering shorthand (4k7). """ given = {k: v for k, v in (("voltage", body.voltage), ("current", body.current), ("resistance", body.resistance), ("power", body.power)) if v is not None} if len(given) < 2: return ("CANNOT DO THAT: provide exactly two of voltage, current, resistance or power. " f"You gave {len(given)}. Ask the user for the missing value.") try: vals = {k: parse(v, k) for k, v in given.items()} except UnitError as exc: return f"CANNOT DO THAT: {exc}" v, i, r, p = vals.get("voltage"), vals.get("current"), vals.get("resistance"), vals.get("power") steps: list[str] = [] try: if v is not None and i is not None: r = v / i if i else math.inf p = v * i steps += ["R = V / I", "P = V × I"] elif v is not None and r is not None: if r == 0: return "CANNOT DO THAT: resistance of zero gives infinite current (a short circuit)." i = v / r p = v * v / r steps += ["I = V / R", "P = V² / R"] elif v is not None and p is not None: if v == 0: return "CANNOT DO THAT: voltage of zero with non-zero power is not physical." i = p / v r = v * v / p if p else math.inf steps += ["I = P / V", "R = V² / P"] elif i is not None and r is not None: v = i * r p = i * i * r steps += ["V = I × R", "P = I² × R"] elif i is not None and p is not None: if i == 0: return "CANNOT DO THAT: current of zero with non-zero power is not physical." v = p / i r = p / (i * i) steps += ["V = P / I", "R = P / I²"] elif r is not None and p is not None: if r <= 0: return "CANNOT DO THAT: resistance must be greater than zero." v = math.sqrt(p * r) i = math.sqrt(p / r) steps += ["V = √(P × R)", "I = √(P / R)"] else: return "CANNOT DO THAT: that combination of values is not enough to solve." except ZeroDivisionError: return "CANNOT DO THAT: those values divide by zero." lines = [ "# Ohm's law", "", f"Voltage: {eng(v, 'V')}", f"Current: {eng(i, 'A')}", f"Resistance: {eng(r, 'Ω')}", f"Power: {eng(p, 'W')}", "", f"Given {' and '.join(given)}; used {', '.join(steps)}.", ] if p > 0.25: lines += ["", f"Note: {eng(p, 'W')} dissipation needs a resistor rated well above that. " "A common rule is to pick at least twice the calculated power."] return "\n".join(lines) # ----------------------------------------------------------- component_calc COMPONENT_MODES = Literal[ "resistor_color_code", "resistor_to_color", "nearest_e_series", "series_parallel", "rc_filter", "rl_filter", "lc_resonance", "reactance", "voltage_divider", ] class ComponentRequest(ToolModel): calculation: COMPONENT_MODES = Field( description="resistor_color_code=decode colour bands (put them in `text`); " "resistor_to_color=value to colour bands (in `value`); " "nearest_e_series=closest standard value; series_parallel=combine components " "(list them in `text`); rc_filter=RC time constant and cutoff; rl_filter=RL " "time constant and cutoff; lc_resonance=LC resonant frequency; " "reactance=capacitive or inductive reactance at a frequency; " "voltage_divider=output of a two-resistor divider.", ) text: str | None = Field( default=None, description="Colour band names ('brown black red gold'), or a list of values to combine " "('10k, 4.7k, 2.2k').", ) value: str | None = Field(default=None, description="A single component value.") resistance: str | None = Field(default=None, description="Resistance in ohms.") resistance2: str | None = Field( default=None, description="Second resistance in ohms - the lower, ground-side resistor of a divider.") capacitance: str | None = Field(default=None, description="Capacitance in farads.") inductance: str | None = Field(default=None, description="Inductance in henries.") frequency: str | None = Field(default=None, description="Frequency in hertz.") voltage: str | None = Field(default=None, description="Input voltage in volts.") combination: Literal["series", "parallel"] = Field( default="parallel", description="For series_parallel. Default parallel.") series: Literal["E6", "E12", "E24", "E48", "E96"] = Field( default="E24", description="For nearest_e_series. E24 (default) is the usual 5% range, E96 the 1% range.") component_type: Literal["resistor", "capacitor", "inductor"] = Field( default="resistor", description="For series_parallel; capacitors combine the opposite way to resistors. " "Default resistor.") @router.post("/component_calc", operation_id="component_calc", response_model=str, summary="Passive component calculations") @tool async def component_calc(body: ComponentRequest) -> str: """Resistor colour codes, E-series values, series/parallel, RC/RL/LC networks, reactance, dividers. Use it rather than working any of it out yourself - colour codes and E-series values are pure lookup. Set `calculation` and fill in only the parameters it uses; you are told which are missing. Values accept plain numbers (4700), SI prefixes (4.7k, 100n, 2.2u) or engineering shorthand (4k7). """ values = { "resistance": body.resistance, "resistance2": body.resistance2, "capacitance": body.capacitance, "inductance": body.inductance, "frequency": body.frequency, "voltage": body.voltage, "value": body.value, } try: return _dispatch_component(body, values) except UnitError as exc: return f"CANNOT DO THAT: {exc}" def _dispatch_component(body: ComponentRequest, values: dict) -> str: mode = body.calculation if mode == "resistor_color_code": return _decode_color_bands(body.text or "") if mode == "resistor_to_color": (value,) = require(values, "value") return _encode_color_bands(value) if mode == "nearest_e_series": (value,) = require(values, "value") return _nearest_e_series(value, body.series) if mode == "series_parallel": return _series_parallel(body.text or "", body.combination, body.component_type) if mode == "rc_filter": r, c = require(values, "resistance", "capacitance") if r <= 0 or c <= 0: raise UnitError("resistance and capacitance must both be greater than zero.") tau = r * c fc = 1.0 / (2 * math.pi * tau) return "\n".join([ "# RC network", "", f"R = {eng(r, 'Ω')}, C = {eng(c, 'F')}", "", f"Time constant τ = R × C = {eng(tau, 's')}", f"Cutoff frequency f = 1 / (2πRC) = {eng(fc, 'Hz')}", f"Rise time (10-90%) ≈ 2.2τ = {eng(2.2 * tau, 's')}", f"To 99% of final value ≈ 5τ = {eng(5 * tau, 's')}", "", "As a low-pass filter this passes below the cutoff; as a high-pass, above it. " "Roll-off is 20 dB/decade either way.", ]) if mode == "rl_filter": r, l = require(values, "resistance", "inductance") if r <= 0 or l <= 0: raise UnitError("resistance and inductance must both be greater than zero.") tau = l / r fc = r / (2 * math.pi * l) return "\n".join([ "# RL network", "", f"R = {eng(r, 'Ω')}, L = {eng(l, 'H')}", "", f"Time constant τ = L / R = {eng(tau, 's')}", f"Cutoff frequency f = R / (2πL) = {eng(fc, 'Hz')}", f"To 99% of final current ≈ 5τ = {eng(5 * tau, 's')}", ]) if mode == "lc_resonance": l, c = require(values, "inductance", "capacitance") if l <= 0 or c <= 0: raise UnitError("inductance and capacitance must both be greater than zero.") f0 = 1.0 / (2 * math.pi * math.sqrt(l * c)) z = math.sqrt(l / c) return "\n".join([ "# LC resonant circuit", "", f"L = {eng(l, 'H')}, C = {eng(c, 'F')}", "", f"Resonant frequency f₀ = 1 / (2π√(LC)) = {eng(f0, 'Hz')}", f"Characteristic impedance √(L/C) = {eng(z, 'Ω')}", "", "At resonance a series LC looks like a short and a parallel LC like an open circuit.", ]) if mode == "reactance": (freq,) = require(values, "frequency") if freq <= 0: raise UnitError("frequency must be greater than zero.") lines = [f"# Reactance at {eng(freq, 'Hz')}", ""] if body.capacitance is not None: c = parse(body.capacitance, "capacitance") xc = 1.0 / (2 * math.pi * freq * c) if c > 0 else math.inf lines.append(f"Capacitive reactance Xc = 1 / (2πfC) = {eng(xc, 'Ω')} (C = {eng(c, 'F')})") if body.inductance is not None: l = parse(body.inductance, "inductance") xl = 2 * math.pi * freq * l lines.append(f"Inductive reactance Xl = 2πfL = {eng(xl, 'Ω')} (L = {eng(l, 'H')})") if len(lines) == 2: raise UnitError("provide a capacitance, an inductance, or both.") lines += ["", "Capacitive reactance falls with frequency; inductive reactance rises."] return "\n".join(lines) if mode == "voltage_divider": vin, r1, r2 = require(values, "voltage", "resistance", "resistance2") if r1 + r2 <= 0: raise UnitError("the two resistances must add up to more than zero.") vout = vin * r2 / (r1 + r2) current = vin / (r1 + r2) return "\n".join([ "# Voltage divider", "", f"Vin = {eng(vin, 'V')}, R1 (top) = {eng(r1, 'Ω')}, R2 (bottom) = {eng(r2, 'Ω')}", "", f"**Vout = Vin × R2 / (R1 + R2) = {eng(vout, 'V')}**", "", f"Current through the divider: {eng(current, 'A')}", f"Dissipation: R1 {eng(current ** 2 * r1, 'W')}, R2 {eng(current ** 2 * r2, 'W')}", "", "This assumes nothing is drawing current from the output. Any load in parallel with " "R2 pulls Vout down - keep the divider current well above the load current, or buffer " "it with an op-amp.", ]) raise UnitError(f"unknown calculation '{mode}'") _BAND_COLORS = { "black": 0, "brown": 1, "red": 2, "orange": 3, "yellow": 4, "green": 5, "blue": 6, "violet": 7, "purple": 7, "grey": 8, "gray": 8, "white": 9, } _MULTIPLIERS = {**_BAND_COLORS, "gold": -1, "silver": -2} _TOLERANCES = {"brown": "±1%", "red": "±2%", "green": "±0.5%", "blue": "±0.25%", "violet": "±0.1%", "purple": "±0.1%", "grey": "±0.05%", "gray": "±0.05%", "gold": "±5%", "silver": "±10%", "none": "±20%"} def _decode_color_bands(text: str) -> str: words = [w for w in re.split(r"[,\s/;-]+", text.lower().strip()) if w] if len(words) < 3: return ("CANNOT DO THAT: give at least three colour bands, e.g. " "'brown black red gold'. Put them in the `text` parameter in order.") unknown = [w for w in words if w not in _MULTIPLIERS and w != "none"] if unknown: return (f"CANNOT DO THAT: '{', '.join(unknown)}' {'is not a' if len(unknown) == 1 else 'are not'} " "resistor band colour. Valid colours are black, brown, red, orange, yellow, green, " "blue, violet, grey, white, gold, silver.") tolerance = "±20% (no tolerance band)" bands = words[:] if len(bands) in (4, 5) and bands[-1] in _TOLERANCES: tolerance = _TOLERANCES[bands[-1]] bands = bands[:-1] if len(bands) < 3: return "CANNOT DO THAT: not enough significant-figure bands after removing the tolerance band." digits = bands[:-1] multiplier_color = bands[-1] if any(d not in _BAND_COLORS for d in digits): return f"CANNOT DO THAT: {', '.join(digits)} cannot all be significant-figure bands." significant = int("".join(str(_BAND_COLORS[d]) for d in digits)) multiplier = 10 ** _MULTIPLIERS[multiplier_color] value = significant * multiplier return "\n".join([ f"# {' '.join(words)}", "", f"**{eng(value, 'Ω')} {tolerance}**", "", f"Significant figures: {significant} (from {', '.join(digits)})", f"Multiplier: ×{eng(multiplier, '')} (from {multiplier_color})", "", f"Tolerance range: {eng(value * (1 - _tolerance_fraction(tolerance)), 'Ω')} to " f"{eng(value * (1 + _tolerance_fraction(tolerance)), 'Ω')}", ]) def _tolerance_fraction(tolerance: str) -> float: match = re.search(r"([\d.]+)%", tolerance) return float(match.group(1)) / 100 if match else 0.2 def _encode_color_bands(value: float) -> str: if value <= 0: return "CANNOT DO THAT: resistance must be greater than zero." names = {v: k for k, v in _BAND_COLORS.items() if k not in ("purple", "gray")} exponent = math.floor(math.log10(value)) out = [f"# {eng(value, 'Ω')}", ""] for count, label in ((2, "4-band (2 significant figures, ±5%)"), (3, "5-band (3 significant figures, ±1%)")): multiplier_exp = exponent - (count - 1) if not -2 <= multiplier_exp <= 9: out.append(f"{label}: cannot be represented with standard bands.") continue significant = round(value / (10 ** multiplier_exp)) digits = str(significant).zfill(count)[:count] bands = [names[int(d)] for d in digits] mult = {-2: "silver", -1: "gold"}.get(multiplier_exp) or names[multiplier_exp] tol = "gold" if count == 2 else "brown" out.append(f"**{label}:** {' - '.join(bands)} - {mult} - {tol}") out += ["", "Bands are read from the end with the bands closest together; the tolerance band " "is the isolated one."] return "\n".join(out) _E_SERIES = { "E6": [10, 15, 22, 33, 47, 68], "E12": [10, 12, 15, 18, 22, 27, 33, 39, 47, 56, 68, 82], "E24": [10, 11, 12, 13, 15, 16, 18, 20, 22, 24, 27, 30, 33, 36, 39, 43, 47, 51, 56, 62, 68, 75, 82, 91], "E48": [100, 105, 110, 115, 121, 127, 133, 140, 147, 154, 162, 169, 178, 187, 196, 205, 215, 226, 237, 249, 261, 274, 287, 301, 316, 332, 348, 365, 383, 402, 422, 442, 464, 487, 511, 536, 562, 590, 619, 649, 681, 715, 750, 787, 825, 866, 909, 953], "E96": [100, 102, 105, 107, 110, 113, 115, 118, 121, 124, 127, 130, 133, 137, 140, 143, 147, 150, 154, 158, 162, 165, 169, 174, 178, 182, 187, 191, 196, 200, 205, 210, 215, 221, 226, 232, 237, 243, 249, 255, 261, 267, 274, 280, 287, 294, 301, 309, 316, 324, 332, 340, 348, 357, 365, 374, 383, 392, 402, 412, 422, 432, 442, 453, 464, 475, 487, 499, 511, 523, 536, 549, 562, 576, 590, 604, 619, 634, 649, 665, 681, 698, 715, 732, 750, 768, 787, 806, 825, 845, 866, 887, 909, 931, 953, 976], } def _nearest_e_series(value: float, series: str) -> str: if value <= 0: return "CANNOT DO THAT: value must be greater than zero." base = _E_SERIES[series] scale = 10 ** (len(str(base[0])) - 1) candidates: list[float] = [] for decade in range(-4, 8): candidates += [b / scale * (10 ** decade) for b in base] nearest = min(candidates, key=lambda c: abs(math.log10(c) - math.log10(value))) below = max((c for c in candidates if c <= value), default=nearest) above = min((c for c in candidates if c >= value), default=nearest) error = (nearest - value) / value * 100 return "\n".join([ f"# Nearest {series} value to {eng(value, 'Ω')}", "", f"**Closest standard value: {eng(nearest, 'Ω')}** ({error:+.2f}% from what you asked for)", "", f"Next lower: {eng(below, 'Ω')}", f"Next higher: {eng(above, 'Ω')}", "", f"{series} has {len(base)} values per decade" f"{' (5% tolerance parts)' if series in ('E6', 'E12', 'E24') else ' (1% tolerance parts)'}.", ]) def _series_parallel(text: str, combination: str, component_type: str) -> str: raw = [t for t in re.split(r"[,;/\s]+", text.strip()) if t] if len(raw) < 2: return ("CANNOT DO THAT: list at least two component values in the `text` parameter, " "e.g. '10k, 4.7k, 2.2k'.") try: values = [parse(item, "component value") for item in raw] except UnitError as exc: return f"CANNOT DO THAT: {exc}" if any(v <= 0 for v in values): return "CANNOT DO THAT: every component value must be greater than zero." unit = {"resistor": "Ω", "capacitor": "F", "inductor": "H"}[component_type] # Capacitors are the odd one out: they add in parallel and combine # reciprocally in series, the reverse of resistors and inductors. reciprocal = (combination == "parallel") != (component_type == "capacitor") if reciprocal: total = 1.0 / sum(1.0 / v for v in values) formula = "1 / (1/x₁ + 1/x₂ + …)" else: total = sum(values) formula = "x₁ + x₂ + …" listed = ", ".join(eng(v, unit) for v in values) lines = [ f"# {len(values)} {component_type}s in {combination}", "", f"Values: {listed}", "", f"**Total: {eng(total, unit)}**", "", f"Formula: {formula}", ] if component_type == "capacitor": lines += ["", "Note: capacitors combine the opposite way to resistors - they add in " "parallel and combine reciprocally in series."] return "\n".join(lines) # ------------------------------------------------------------------ pcb_calc class PcbRequest(ToolModel): calculation: Literal["trace_width", "microstrip_impedance", "stripline_impedance", "microstrip_width_for_impedance", "stripline_width_for_impedance", "via_current"] = Field( description="trace_width=minimum copper width for a current (IPC-2221); " "microstrip_impedance=impedance of a surface trace over a plane; " "stripline_impedance=impedance of a buried trace between two planes; " "microstrip_width_for_impedance and stripline_width_for_impedance=the width " "needed to hit `target_impedance`; via_current=current capacity of a plated " "via.", ) current: str | None = Field(default=None, description="Current in amps.") temperature_rise: float = Field(default=10.0, gt=0, le=100, description="Allowed rise in degrees C above ambient. Default " "10 (conservative); 20-30 where the board runs hot " "anyway.") copper_weight: float = Field(default=1.0, gt=0, le=10, description="Copper thickness in oz per square foot. Default 1; 2 " "for power boards.") layer: Literal["external", "internal"] = Field( default="external", description="external (default) or internal. Internal traces carry roughly half the " "current for the same width.") trace_width: str | None = Field(default=None, description="Trace width for the impedance " "calculations. mm by default; a unit " "suffix (0.3mm, 12mil, 300um) is read.") dielectric_height: str | None = Field(default=None, description="Dielectric height between trace and " "plane. mm by default; a unit suffix " "(0.2mm, 8mil) is read.") dielectric_constant: float = Field(default=4.3, gt=1, le=20, description="Relative permittivity (Er). Default 4.3, FR-4.") target_impedance: str | None = Field( default=None, description="Target impedance in ohms, e.g. '50', for the width_for_impedance " "calculations.") via_diameter: str | None = Field(default=None, description="Finished via hole diameter. mm by " "default; a unit suffix (0.3mm, 12mil) " "is read.") plating_thickness: float = Field(default=0.025, gt=0, description="Via barrel plating in mm. Default 0.025 (25 " "microns).") @router.post("/pcb_calc", operation_id="pcb_calc", response_model=str, summary="PCB trace width and impedance") @tool async def pcb_calc(body: PcbRequest) -> str: """PCB trace width for a current, microstrip/stripline impedance or width for a target, via capacity. Always use it rather than estimating: the IPC-2221 power law cannot be done in your head, and an undersized trace is a fire risk. Results name the standard or formula they come from. """ try: return _dispatch_pcb(body) except UnitError as exc: return f"CANNOT DO THAT: {exc}" # Board dimensions arrive as "1.6mm", "8 mil", "35um" as often as bare # numbers, and the generic engineering parser reads a trailing "mm" as the # SI prefix milli - so "1.6mm" became 0.0016 mm, the microstrip formula went # negative, and a graded run printed Z0 = -46.5 ohm as though it were a # bound. The same wrong input recurred in the next round. Lengths get their # own parser. _LENGTH_RE = re.compile( r"^([+-]?\d*\.?\d+(?:[eE][+-]?\d+)?)\s*" r"(mm|millimet(?:er|re)s?|mils?|thou|um|µm|μm|microns?|micromet(?:er|re)s?|cm|in|inch(?:es)?|\"|m)?$", re.I, ) _LENGTH_MM = { "mm": 1.0, "mil": 0.0254, "mils": 0.0254, "thou": 0.0254, "um": 0.001, "µm": 0.001, "μm": 0.001, "micron": 0.001, "microns": 0.001, "cm": 10.0, "in": 25.4, "inch": 25.4, "inches": 25.4, '"': 25.4, } # Cable runs arrive in feet as often as metres, and the engineering parser # reads a trailing "ft" as femto: "100ft" became 1e-13 m, the drop came back # as 0.00% and was reported as a success, and a graded run then did the # arithmetic by hand and got one figure wrong by 4x. Same defect the board # dimensions had; same fix. _CABLE_RE = re.compile( r"^([+-]?\d*\.?\d+(?:[eE][+-]?\d+)?)\s*" r"(m|metres?|meters?|km|kilomet(?:er|re)s?|cm|mm|ft|feet|foot|'|in|inch(?:es)?|\"|yd|yards?)?$", re.I, ) _CABLE_M = { "m": 1.0, "metre": 1.0, "metres": 1.0, "meter": 1.0, "meters": 1.0, "km": 1000.0, "kilometer": 1000.0, "kilometers": 1000.0, "kilometre": 1000.0, "kilometres": 1000.0, "cm": 0.01, "mm": 0.001, "ft": 0.3048, "feet": 0.3048, "foot": 0.3048, "'": 0.3048, "in": 0.0254, "inch": 0.0254, "inches": 0.0254, '"': 0.0254, "yd": 0.9144, "yard": 0.9144, "yards": 0.9144, } def _cable_length_m(value, name: str = "length") -> tuple[float, str]: """A cable run in metres, plus how to print it ("30.48 m (100 ft)").""" if value is None: raise UnitError(f"{name} is required for this calculation but was not provided.") if isinstance(value, (int, float)): return float(value), f"{float(value):g} m" text = str(value).strip().replace(",", "") found = _CABLE_RE.match(text) if not found: raise UnitError(f"Could not read '{value}' as a cable length for {name}. Give metres as a " "plain number (30), or add a unit: 30 m, 100 ft, 250 cm, 0.5 km.") number, unit = float(found.group(1)), (found.group(2) or "m").lower() metres = number * _CABLE_M[unit] shown = f"{metres:g} m" if unit == "m" else f"{metres:.4g} m ({number:g} {unit})" return metres, shown def _length_mm(value, name: str) -> float: """A board dimension in millimetres, accepting the units a layout uses.""" if value is None: raise UnitError(f"{name} is required for this calculation but was not provided.") if isinstance(value, (int, float)): return float(value) text = str(value).strip().replace(",", "") found = _LENGTH_RE.match(text) if not found: raise UnitError(f"Could not read '{value}' as a length for {name}. Give millimetres as a plain " "number (0.3), or add a unit: 0.3mm, 12mil, 300um.") number, unit = float(found.group(1)), (found.group(2) or "mm").lower() if unit == "m": raise UnitError(f"'{value}' for {name} reads as metres, which is not a board dimension. Give " "millimetres (0.3 or 0.3mm), mils (12mil) or microns (300um).") if unit.startswith("millimet"): unit = "mm" elif unit.startswith("micromet"): unit = "um" return number * _LENGTH_MM[unit] def _dispatch_pcb(body: PcbRequest) -> str: mode = body.calculation if mode == "trace_width": if body.current is None: raise UnitError("current is required to calculate a trace width.") current = parse(body.current, "current") if current <= 0: raise UnitError("current must be greater than zero.") # IPC-2221: A = (I / (k · ΔT^0.44))^(1/0.725), area in mils². k = 0.048 if body.layer == "external" else 0.024 area_mils2 = (current / (k * body.temperature_rise ** 0.44)) ** (1 / 0.725) # 1 oz/ft² of copper is 1.378 mils thick. width_mils = area_mils2 / (body.copper_weight * 1.378) width_mm = width_mils * 0.0254 other_k = 0.024 if body.layer == "external" else 0.048 other_area = (current / (other_k * body.temperature_rise ** 0.44)) ** (1 / 0.725) other_mm = other_area / (body.copper_weight * 1.378) * 0.0254 alt_oz = 1.0 if body.copper_weight >= 2 else 2.0 alt_mils = area_mils2 / (alt_oz * 1.378) alt_mm = alt_mils * 0.0254 return "\n".join([ f"# Trace width for {eng(current, 'A')}", "", f"**Minimum width: {width_mm:.3f} mm ({width_mils:.1f} mil)**", "", f"Layer: {body.layer}", f"Copper weight: {body.copper_weight} oz ({body.copper_weight * 1.378:.2f} mil thick)", f"Temperature rise: {body.temperature_rise:.0f} °C above ambient", f"Cross-sectional area: {area_mils2:.1f} mil²", "", f"For comparison, an {'internal' if body.layer == 'external' else 'external'} layer " f"would need {other_mm:.3f} mm.", # The required AREA does not depend on copper weight, so the width # scales inversely with thickness: 2 oz halves it. A graded review # said "2 oz cuts it to roughly 38 mil" for a 54 mil trace - a # square-root scaling the formula does not have. f"At {alt_oz:g} oz copper the same {area_mils2:.1f} mil² needs {alt_mils:.1f} mil " f"({alt_mm:.3f} mm) - width scales inversely with copper thickness, not with its " "square root.", "", "From IPC-2221, A = (I / (k·ΔT^0.44))^(1/0.725) with k=0.048 external, 0.024 internal.", "", "**An internal trace needs 2.60x the width of an external one, not 2x.** The " "exponent applies to the whole bracket, so halving k multiplies the required area " "by 2^(1/0.725) = 2^1.379 = 2.60 - not by 2. Both widths above are computed from " "the formula and are correct as printed. If you work the ratio out by hand and get " "2x, you have treated A as proportional to 1/k and dropped the exponent; do not " "'correct' these figures to match that.", "", "This is the minimum to stay within the temperature rise. Add margin for long traces " "(voltage drop becomes the limit before heating does), for high ambient temperatures, " "and near connectors.", "", # The sentence this replaces said IPC-2152 was "usually less # conservative ... if you need to squeeze". For an external trace # that is backwards, and it was read as licence to invent a number: # asked for a 3 A external trace the model took the 54 mil this # tool printed, "adjusted" it to ~40 mil for IPC-2152 and put that # in its bottom line. IPC-2152 puts the same trace at roughly # 76-89 mil. Undersizing a 3 A trace by half is the one failure # mode this whole module exists to prevent. "About IPC-2152: it replaced this IPC-2221 chart in 2009, and it is not a way to " "shrink an external trace. For external traces IPC-2152 gives widths of the same " "order or larger than the IPC-2221 external curve, because that curve assumed more " "cooling than a real board provides; it is internal traces that it rates less " "conservatively. This tool computes IPC-2221 only. Do not quote an IPC-2152 width " "unless you read it from an IPC-2152 chart or calculator.", ]) if mode == "microstrip_impedance": if body.trace_width is None or body.dielectric_height is None: raise UnitError("trace_width and dielectric_height (both in mm) are required.") w = _length_mm(body.trace_width, "trace_width") h = _length_mm(body.dielectric_height, "dielectric_height") if w <= 0 or h <= 0: raise UnitError("trace width and dielectric height must be greater than zero.") t = body.copper_weight * 0.0348 # oz -> mm er = body.dielectric_constant z0 = _microstrip_z0(w, h, t, er) if z0 <= 0: return _out_of_range(w, h, t) er_eff = (er + 1) / 2 + (er - 1) / 2 * (1 + 12 * h / w) ** -0.5 delay = 3.336 * math.sqrt(er_eff) # ps/mm note = _wh_caution(w, h) return "\n".join([ "# Microstrip impedance", "", f"**Z₀ ≈ {z0:.1f} Ω**", "", f"Trace width: {w:.3f} mm", f"Dielectric height: {h:.3f} mm", f"Copper thickness: {t:.4f} mm ({body.copper_weight} oz)", f"Er: {er}", f"Effective Er: {er_eff:.2f}", f"Propagation delay: {delay:.1f} ps/mm", "", "From IPC-2141: Z₀ = (87/√(Er+1.41)) · ln(5.98h / (0.8w + t)).", "Widening the trace lowers impedance; more dielectric height raises it." + note, ]) if mode == "stripline_impedance": if body.trace_width is None or body.dielectric_height is None: raise UnitError("trace_width and dielectric_height (plane-to-plane separation, mm) are required.") w = _length_mm(body.trace_width, "trace_width") b = _length_mm(body.dielectric_height, "dielectric_height") if w <= 0 or b <= 0: raise UnitError("trace width and plane separation must be greater than zero.") t = body.copper_weight * 0.0348 er = body.dielectric_constant z0 = _stripline_z0(w, b, t, er) if z0 <= 0: return _out_of_range(w, b, t) delay = 3.336 * math.sqrt(er) return "\n".join([ "# Stripline impedance", "", f"**Z₀ ≈ {z0:.1f} Ω**", "", f"Trace width: {w:.3f} mm", f"Plane-to-plane separation: {b:.3f} mm", f"Copper thickness: {t:.4f} mm", f"Er: {er}", f"Propagation delay: {delay:.1f} ps/mm", "", "From IPC-2141: Z₀ = (60/√Er) · ln(4b / (0.67π(0.8w + t))), for a trace centred " "between the planes.", "A stripline is slower than an equivalent microstrip because all its field is in " "the dielectric rather than partly in air.", ]) if mode in ("microstrip_width_for_impedance", "stripline_width_for_impedance"): microstrip = mode == "microstrip_width_for_impedance" if body.target_impedance is None: raise UnitError("target_impedance is required to solve for a width - give it in " "ohms, e.g. '50'.") target = parse(body.target_impedance, "target_impedance") if target <= 0: raise UnitError("target_impedance must be greater than zero.") if body.dielectric_height is None: raise UnitError( "dielectric_height (in mm) is required: impedance is set by the ratio of trace " "width to dielectric height, so there is no width for a target impedance until " "the height is known." if microstrip else "dielectric_height (plane-to-plane separation, mm) is required: impedance is set " "by the ratio of trace width to that separation, so there is no width for a " "target impedance until it is known.") h = _length_mm(body.dielectric_height, "dielectric_height") if h <= 0: raise UnitError("dielectric height must be greater than zero.") t = body.copper_weight * 0.0348 # oz -> mm er = body.dielectric_constant solve = ((lambda width: _microstrip_z0(width, h, t, er)) if microstrip else (lambda width: _stripline_z0(width, h, t, er))) w = _width_for_impedance(solve, target) if w is None: z_narrow = solve(_W_MIN_MM) if z_narrow <= 0: return _out_of_range(_W_MIN_MM, h, t) if target > z_narrow: return (f"CANNOT DO THAT: {target:g} Ω is higher than this stackup reaches. With " f"Er {er}, {h:.3f} mm of dielectric and {t:.4f} mm of copper, even a " f"{_W_MIN_MM} mm trace - narrower than any normal process - is only " f"{z_narrow:.1f} Ω, and that is the widest impedance available here. " "Impedance follows the ratio of width to dielectric height, so a higher " "impedance comes from a thicker dielectric or a layer further from its " "reference plane, not from a narrower trace.") return (f"CANNOT DO THAT: {target:g} Ω would need a trace wider than " f"{_W_MAX_MM:.0f} mm with {h:.3f} mm of dielectric, which is not a trace. " "Reduce the dielectric height instead - a low impedance needs the reference " "plane close to the trace.") achieved = solve(w) if microstrip: heading = f"# Microstrip width for {target:g} Ω" height_line = f"Dielectric height: {h:.3f} mm" formula = ("From IPC-2141: Z₀ = (87/√(Er+1.41)) · ln(5.98h / (0.8w + t)), " "inverted numerically for w.") else: heading = f"# Stripline width for {target:g} Ω" height_line = f"Plane-to-plane separation: {h:.3f} mm" formula = ("From IPC-2141: Z₀ = (60/√Er) · ln(4b / (0.67π(0.8w + t))), inverted " "numerically for w, for a trace centred between the planes.") return "\n".join([ heading, "", f"**Width for {target:g} Ω: {w:.3f} mm ({w / 0.0254:.1f} mil)**", "", height_line, f"Copper thickness: {t:.4f} mm ({body.copper_weight} oz)", f"Er: {er}", f"Impedance at that width: {achieved:.2f} Ω", "", formula, "The width is found by bisection on the same function " f"{'microstrip' if microstrip else 'stripline'}_impedance uses, so feeding this width " "back into that calculation returns the impedance above.", "Widening the trace lowers impedance; more dielectric height raises it." + (_wh_caution(w, h) if microstrip else ""), ]) if mode == "via_current": if body.via_diameter is None or body.current is None: raise UnitError("via_diameter (mm) and current (A) are required.") d = _length_mm(body.via_diameter, "via_diameter") current = parse(body.current, "current") if d <= 0: raise UnitError("via diameter must be greater than zero.") plating = body.plating_thickness # The barrel is an annulus; unrolled it is a trace of that width. area_mm2 = math.pi * ((d / 2 + plating) ** 2 - (d / 2) ** 2) equivalent_mils2 = area_mm2 / (0.0254 ** 2) capacity = 0.048 * body.temperature_rise ** 0.44 * equivalent_mils2 ** 0.725 verdict = (f"**One via is enough** ({capacity:.2f} A capacity vs {current:.2f} A needed)." if capacity >= current else f"**Not enough — use at least {math.ceil(current / capacity)} vias** " f"({capacity:.2f} A each vs {current:.2f} A needed).") return "\n".join([ f"# Via current capacity", "", verdict, "", f"Via diameter: {d:.3f} mm", f"Plating thickness: {plating * 1000:.0f} µm", f"Barrel cross-section: {area_mm2:.5f} mm²", f"Temperature rise allowed: {body.temperature_rise:.0f} °C", "", "Treats the plated barrel as an unrolled trace and applies the IPC-2221 external " "formula. Vias also conduct heat, so several small vias usually beat one large one " "under a hot part.", ]) raise UnitError(f"unknown calculation '{mode}'") # The two IPC-2141 impedance formulas live in functions rather than inline in # their branches because the width-for-impedance modes invert them by # bisection. Solving them a second time in closed form would give two pieces # of code that can drift apart, and a width that the forward calculation then # contradicts is worse than no width at all. def _microstrip_z0(w: float, h: float, t: float, er: float) -> float: """IPC-2141 surface microstrip impedance, all dimensions in mm.""" return (87 / math.sqrt(er + 1.41)) * math.log(5.98 * h / (0.8 * w + t)) def _stripline_z0(w: float, b: float, t: float, er: float) -> float: """IPC-2141 impedance of a trace centred between two planes, mm.""" return (60 / math.sqrt(er)) * math.log(4 * b / (0.67 * math.pi * (0.8 * w + t))) def _out_of_range(w: float, h: float, t: float) -> str: """Refuse rather than print a negative impedance. The IPC-2141 closed forms take the log of a ratio that drops below 1 when the trace is far wider than the dielectric is thick (or the copper thicker than the trace is wide); the number that comes out is negative and means nothing. It used to be printed as though it were a result. """ return (f"CANNOT DO THAT: with a {w:.3f} mm trace, {h:.3f} mm of dielectric and {t:.4f} mm " f"of copper (w/h = {w / h:.1f}), the IPC-2141 formula is outside the range where it " "gives a number at all - the trace is so much wider than the dielectric is thick " "that the log term goes negative. Check the units first: the most common cause is a " "value given in a different unit from the other (mils against millimetres, or " "microns read as millimetres). If the geometry is genuinely this wide, the " "impedance is very low and needs a field solver, not a closed-form estimate.") def _wh_caution(w: float, h: float) -> str: """The w/h validity note, shared by the forward and inverse modes. Both print it from the same place so a width that came out of the bisection cannot arrive without the warning the forward calculation would have attached to it. """ ratio = w / h if 0.1 <= ratio <= 2.0: return "" return (f"\n\n**Caution:** w/h is {ratio:.2f}, outside the 0.1-2.0 range where this " "formula is accurate. Treat the result as indicative and confirm with a field solver.") # Bisection bracket for the width solve, in mm. 0.01 mm is below anything a # fab will etch and 100 mm is far wider than any controlled-impedance line, # so a target outside the impedances these two widths produce is genuinely # out of reach on the given stackup, not merely outside a lazy guess. _W_MIN_MM = 0.01 _W_MAX_MM = 100.0 def _width_for_impedance(z0_of_w, target: float) -> float | None: """Invert an impedance formula by bisecting on trace width. Z₀ falls monotonically as the trace widens - width sits in the denominator of the log argument in both formulas - so plain bisection converges. Returns None when the target lies outside the bracket; the caller says so, because answering "75 Ω" with a 40 m wide trace is the kind of silly number this module exists to avoid. """ lo, hi = _W_MIN_MM, _W_MAX_MM if not z0_of_w(hi) <= target <= z0_of_w(lo): return None w = lo for _ in range(60): w = (lo + hi) / 2 z = z0_of_w(w) if abs(z - target) < 0.01: return w if z > target: lo = w else: hi = w return w # --------------------------------------------------------------- wiring_calc # NEC 2017 Chapter 9, Table 9 — "Alternating-Current Resistance and Reactance for # 600-Volt Cables, 3-Phase, 60 Hz, 75°C (167°F) — Three Single Conductors in Conduit". # Ohms to neutral per KILOMETRE. Extracted from the published table, not estimated. # # This exists because a real answer guessed it. Asked to size a 6 AWG motor feeder, # the assistant reasoned: "For reactance ... approximately 0.08-0.12 Ω/km ... Let me # use X = 0.10 Ω/km as a reasonable assumption." The true value is 0.167 (PVC or # aluminium conduit) or 0.210 (steel) — 67% to 110% higher. At a 0.30 starting power # factor the reactive term dominates, so that guess materially changed the verdict. # `wiring_calc` had resistance but no reactance, so calling the tool would not have # helped either. Now it would. # # key: AWG (0 = 1/0, -1 = 2/0, -2 = 3/0, -3 = 4/0) # value: (XL pvc/aluminium, XL steel, R copper pvc, R copper aluminium, R copper steel) _NEC_TABLE9 = { 14: (0.190, 0.240, 10.2, 10.2, 10.2), 12: (0.177, 0.223, 6.6, 6.6, 6.6), 10: (0.164, 0.207, 3.9, 3.9, 3.9), 8: (0.171, 0.213, 2.56, 2.56, 2.56), 6: (0.167, 0.210, 1.61, 1.61, 1.61), 4: (0.157, 0.197, 1.02, 1.02, 1.02), 3: (0.154, 0.194, 0.82, 0.82, 0.82), 2: (0.148, 0.187, 0.62, 0.66, 0.66), 1: (0.151, 0.187, 0.49, 0.52, 0.52), 0: (0.144, 0.180, 0.39, 0.43, 0.39), -1: (0.141, 0.177, 0.33, 0.33, 0.33), -2: (0.138, 0.171, 0.253, 0.269, 0.259), -3: (0.135, 0.167, 0.203, 0.220, 0.207), } _MOTOR_START_CRITERIA = """## Acceptance criteria for a motor start — use these figures IEEE Std 399-1997 (Brown Book), Table 9-1, critical system voltage levels when starting motors. Minimum allowable voltage, as a percentage of rated: terminals of the starting motor 80% other motors that must reaccelerate 71% AC contactor pick-up 85% DC contactor pick-up 80% contactor hold-in 60-70% solid-state control devices 90% noticeable light flicker 3% change The 80% figure assumes a Design B motor with 150% starting torque driving a constant-torque load. Torque falls with the square of voltage, so 0.80^2 x 150% is about 96% of rated torque — which is why 80%, not something closer to 100%, is the accepted floor. Running-voltage tolerance at the motor is a different question: NEMA MG 1-12.44 allows +/-10% of rated. Steady-state service voltage tolerance is ANSI C84.1 Range A, +/-5%. **There is no 95% (5% drop) starting-voltage rule, and there is no IEEE standard numbered 447.** A 5% figure applied to a motor start is ANSI C84.1's steady-state service tolerance being used for a transient it does not govern. Quote the table above, or look a standard up before you name it — starting criteria are among the easiest things here to invent convincingly.""" _CONDUCTOR_SIZING_NOTE = """Ampacity is a separate question from voltage drop, and for a motor it is not a bare comparison against full-load current: NEC 430.22 single motor, continuous duty — conductor ampacity at least 125% of FLC. (Cite 430.22 itself; subsection (A) is Direct-Current Motor-Rectifier Supplied, a different rule, and has been since the 2014 NEC.) NEC 430.24 feeder supplying several motors — 125% of the largest FLC plus 100% of the rest. NEC 110.14(C) caps the result at the termination temperature rating, normally the 75 C column of Table 310.16 even for 90 C insulation such as THHN. Short-circuit and ground-fault protection is a third calculation (NEC Table 430.52) and its percentage depends on the device: 250% inverse-time breaker, 300% non-time-delay fuse, 175% dual-element time-delay fuse, 800% instantaneous-trip. "Standard-torque" is not an NEC category — the table rows are by motor construction.""" # NEC 2023 Table 310.16 - allowable ampacities, not more than three current-carrying # conductors in a raceway, cable or earth, 30 C ambient. Extracted from the published # table through this suite's own PDF pipeline rather than recalled, for the same reason # Table 9 was: a battery answer quoted 6 AWG copper at "55 A (NEC 75 C)" when 55 A is the # 60 C figure and 75 C is 65 A, and gave 4 AWG as 70 A when 75 C is 85 A - the 60 C column # read under a 75 C heading, mixed with a correct 75 C value for 8 AWG. # (cu60, cu75, cu90, al60, al75, al90); None where the size is not rated. _NEC_310_16 = { 18: (None, None, 14, None, None, None), 16: (None, None, 18, None, None, None), 14: (15, 20, 25, None, None, None), 12: (20, 25, 30, 25, 30, 35), 10: (30, 35, 40, 35, 40, 45), 8: (40, 50, 55, 45, 55, 60), 6: (55, 65, 75, 60, 75, 85), 4: (70, 85, 95, 80, 100, 115), 3: (85, 100, 115, 95, 115, 130), 2: (95, 115, 130, 110, 135, 150), 1: (110, 130, 145, 130, 155, 175), 0: (125, 150, 170, 150, 180, 205), -1: (145, 175, 195, 175, 210, 235), -2: (165, 200, 225, 200, 240, 270), -3: (195, 230, 260, 235, 280, 315), } # NEC 240.4(D): small conductors are capped at these OCPD sizes regardless of ampacity. _SMALL_CONDUCTOR_OCPD = {14: 15, 12: 20, 10: 30} def _ampacity_block(awg: int) -> list[str]: row = _NEC_310_16.get(awg) if row is None: return [] cu60, cu75, cu90, al60, al75, al90 = row fmt = lambda v: f"{v} A" if v else " - " lines = [ "", "NEC Table 310.16 allowable ampacity (<=3 current-carrying conductors, 30 C ambient):", " 60 C 75 C 90 C", f" copper {fmt(cu60):>6} {fmt(cu75):>6} {fmt(cu90):>6}", ] if al75: lines.append(f" aluminium {fmt(al60):>6} {fmt(al75):>6} {fmt(al90):>6}") lines += [ "Use the column matching the **termination** rating, not the insulation: NEC 110.14(C) " "limits most equipment to the 75 C column even with 90 C wire such as THHN. Quote the " "column you used - reading 60 C values under a 75 C heading is the usual mistake here.", ] if cap := _SMALL_CONDUCTOR_OCPD.get(awg): lines.append(f"NEC 240.4(D) also caps overcurrent protection for {awg} AWG at {cap} A " "regardless of the ampacity above.") lines.append("These are 30 C ambient with no bundling. Correct for ambient (310.15(B)(1)) " "and for more than three conductors (310.15(C)(1)) before using them.") return lines def _table9(awg: int, conduit: str) -> tuple[float, float] | None: """Return (R, X) in ohms per km for copper, or None if not tabulated.""" row = _NEC_TABLE9.get(awg) if row is None: return None xl_pvc, xl_steel, r_pvc, r_al, r_steel = row if conduit == "steel": return r_steel, xl_steel if conduit == "aluminum": return r_al, xl_pvc return r_pvc, xl_pvc # Rule-of-thumb ampacity, amps: (chassis wiring, power transmission). # These are the widely used free-air / bundled figures, NOT NEC 310.16. _AWG_AMPACITY = { -3: (380, 302), -2: (328, 239), -1: (283, 190), 0: (245, 150), 1: (211, 119), 2: (181, 94), 3: (158, 75), 4: (135, 60), 5: (118, 47), 6: (101, 37), 7: (89, 30), 8: (73, 24), 9: (64, 19), 10: (55, 15), 11: (47, 12), 12: (41, 9.3), 13: (35, 7.4), 14: (32, 5.9), 15: (28, 4.7), 16: (22, 3.7), 17: (19, 2.9), 18: (16, 2.3), 19: (14, 1.8), 20: (11, 1.5), 21: (9, 1.2), 22: (7, 0.92), 23: (4.7, 0.729), 24: (3.5, 0.577), 25: (2.7, 0.457), 26: (2.2, 0.361), 27: (1.7, 0.288), 28: (1.4, 0.226), 29: (1.2, 0.182), 30: (0.86, 0.142), } _MAINS_WARNING = ( "\n\n**For mains or building wiring, follow your local electrical code (NEC 310.16 in the " "US, BS 7671 in the UK) rather than these figures.** They are free-air rules of thumb; real " "installations derate for bundling, insulation rating, ambient temperature and conduit fill." ) class WiringRequest(ToolModel): calculation: Literal["awg_properties", "voltage_drop", "three_phase_drop", "single_phase_drop", "led_resistor", "decibels", "battery_life"] = Field( description="awg_properties=diameter, area, resistance and current rating of a gauge; " "voltage_drop=DC drop over a cable run; single_phase_drop=1-phase AC drop from " "NEC Table 9 resistance and reactance - use this, not voltage_drop, for any " "mains AC run; three_phase_drop=3-phase drop from the same table, needed for " "motors and low power factor; led_resistor=series resistor for an LED; " "decibels=convert between dB, dBm, watts and ratios; battery_life=runtime from " "capacity and load.", ) awg: int | None = Field(default=None, ge=-3, le=40, description="American Wire Gauge. 0 for 1/0, -1 for 2/0, -2 for 3/0, " "-3 for 4/0.") length: str | None = Field(default=None, description="One-way cable length. Metres by default; " "a unit may be attached: 30 m, 100 ft, 250 cm, 0.5 km.") current: str | None = Field(default=None, description="Current in amps.") voltage: str | None = Field(default=None, description="Supply voltage in volts.") led_forward_voltage: str | None = Field(default=None, description="LED forward voltage in volts. Red " "~1.8-2.0, green ~2.1, blue/white " "~3.0-3.4.") led_current: str | None = Field(default=None, description="LED current in amps, e.g. 0.02 for 20 mA.") power_factor: float | None = Field( default=None, ge=0.0, le=1.0, description="Load power factor for the AC drops, e.g. 0.82 running or 0.30 during motor " "starting; reactance dominates at low PF.") conduit: Literal["pvc", "aluminum", "steel"] = Field( default="pvc", description="Raceway type for the AC drops, default pvc. Steel raises reactance by about " "25%.") value: str | None = Field(default=None, description="Input value for the decibel conversion.") db_from: Literal["dB", "dBm", "watts", "milliwatts", "ratio"] = Field( default="dBm", description="Unit of `value` for the decibel conversion, default dBm.") capacity_mah: float | None = Field(default=None, gt=0, description="Battery capacity in mAh.") load_ma: float | None = Field(default=None, gt=0, description="Average load current in mA.") @router.post("/wiring_calc", operation_id="wiring_calc", response_model=str, summary="Wire gauge, voltage drop, LED resistor, decibels") @tool async def wiring_calc(body: WiringRequest) -> str: """Wire gauge properties, DC and AC cable voltage drop, LED series resistors, decibels, battery life. Use it for any wire sizing or dB maths - dBm and dB are not interchangeable and the 10-vs-20 log factor is a common confident error. Wire ampacity figures are free-air rules of thumb: for mains or building wiring the local electrical code governs, and say so. Values accept plain numbers, SI prefixes (4.7k, 100n) or engineering shorthand (4k7). """ try: return _dispatch_wiring(body) except UnitError as exc: return f"CANNOT DO THAT: {exc}" def _awg_geometry(awg: int) -> tuple[float, float, float]: """Return (diameter_mm, area_mm2, ohms_per_km) for copper at 20 °C.""" diameter_mm = 0.127 * (92 ** ((36 - awg) / 39)) area_mm2 = math.pi * (diameter_mm / 2) ** 2 # ρ_copper = 1.724e-8 Ω·m, so R per km = 17.24 / A(mm²). ohms_per_km = 17.24 / area_mm2 return diameter_mm, area_mm2, ohms_per_km def _dispatch_wiring(body: WiringRequest) -> str: mode = body.calculation if mode == "awg_properties": if body.awg is None: raise UnitError("awg is required, e.g. 14 for 14 AWG.") diameter, area, ohms_km = _awg_geometry(body.awg) chassis, transmission = _AWG_AMPACITY.get(body.awg, (None, None)) label = {0: "1/0", -1: "2/0", -2: "3/0", -3: "4/0"}.get(body.awg, str(body.awg)) lines = [ f"# {label} AWG", "", f"Diameter: {diameter:.3f} mm ({diameter / 25.4 * 1000:.1f} mil)", f"Cross-section: {area:.4f} mm²", f"Resistance (copper): {ohms_km:.3f} Ω/km ({ohms_km / 1000 * 0.3048:.5f} Ω/ft)", ] if chassis: lines += [ "", f"Current rating, chassis/free-air wiring: {chassis} A", f"Current rating, power transmission: {transmission} A", "", "The two figures differ because power transmission assumes bundled cable in a " "conduit where heat cannot escape, so it is far more conservative.", ] lines += _ampacity_block(body.awg) rx = _table9(body.awg, "pvc") if rx: r_km, x_km = rx rx_steel = _table9(body.awg, "steel") lines += [ "", "AC values from NEC Ch.9 Table 9 (75 °C, 60 Hz, 3 single conductors in conduit):", f" PVC/aluminium conduit: R {r_km:.3f} Ω/km, X {x_km:.3f} Ω/km", f" steel conduit: R {rx_steel[0]:.3f} Ω/km, X {rx_steel[1]:.3f} Ω/km", "", "**Do not work the AC drop out by hand from these.** Call this tool again with " "calculation=three_phase_drop (awg, length, current, voltage, power_factor, " "conduit) and it returns the drop, the percentage and the voltage at the load.", "Hand-worked three-phase drops are wrong more often than they are right: the √3 " "factor gets written into the formula and then left out of the multiplication, " "which understates the answer by 1.73x and turns a marginal feeder into a " "passing one.", ] lines.append(_MAINS_WARNING) return "\n".join(lines) if mode == "single_phase_drop": if body.awg is None: raise UnitError("awg is required for a single-phase voltage drop.") pf = 1.0 if body.power_factor is None else body.power_factor volts, current = require({"voltage": body.voltage, "current": body.current}, "voltage", "current") length, length_shown = _cable_length_m(body.length) rx = _table9(body.awg, body.conduit) if rx is None: raise UnitError( f"{body.awg} AWG is not in NEC Chapter 9 Table 9 (tabulated 14 AWG through 4/0). " "Do not substitute an assumed reactance - say the size is outside the table.") r_km, x_km = rx sin_phi = math.sqrt(max(0.0, 1 - pf * pf)) r_ohm = r_km / 1000 * length x_ohm = x_km / 1000 * length # Single phase: current goes out and back, so both conductors count. resistive = 2 * current * r_ohm * pf reactive = 2 * current * x_ohm * sin_phi drop = resistive + reactive pct = drop / volts * 100 if volts else 0.0 label = {0: "1/0", -1: "2/0", -2: "3/0", -3: "4/0"}.get(body.awg, str(body.awg)) lines = [ f"# 1-phase voltage drop - {label} AWG copper, {length_shown} one way, " f"{current:g} A at PF {pf:g}", "", f"**Drop: {eng(drop, 'V')} = {pct:.2f}% of {eng(volts, 'V')}**", f"Voltage at the load: {eng(volts - drop, 'V')}", "", "## Where it comes from", f"NEC Ch.9 Table 9, {body.conduit} conduit, 75 C, 60 Hz:", f" R = {r_km:.3f} ohm/km X = {x_km:.3f} ohm/km", f"Over {length:g} m one way: R = {eng(r_ohm, 'ohm')}, X = {eng(x_ohm, 'ohm')}", "", f"Vdrop = 2 x I x (R.cos(phi) + X.sin(phi)) with cos(phi) = {pf:g}, " f"sin(phi) = {sin_phi:.3f}", f" resistive term: {eng(resistive, 'V')}", f" reactive term: {eng(reactive, 'V')}", "", "The factor of 2 is the return conductor: single-phase current goes out and comes " "back, so both legs drop. A 3-phase run uses sqrt(3) instead, not 2.", "", "These are the AC values from Table 9, which include skin and proximity effects. " "The DC resistance in awg_properties is about 20% lower and understates a mains " "drop - do not use it for an AC run.", ] lines += _ampacity_block(body.awg) lines.append(_MAINS_WARNING) return "\n".join(lines) if mode == "voltage_drop": if body.awg is None: raise UnitError("awg is required for a voltage drop calculation.") (current,) = require({"current": body.current}, "current") length, length_shown = _cable_length_m(body.length) if length <= 0 or current <= 0: raise UnitError("length and current must both be greater than zero.") diameter, area, ohms_km = _awg_geometry(body.awg) # Round trip: current flows out and back. resistance = ohms_km / 1000 * length * 2 drop = current * resistance loss = current * current * resistance lines = [ f"# Voltage drop over {length_shown} of {body.awg} AWG", "", f"**Drop: {eng(drop, 'V')}** (round trip, {length * 2:.4g} m of conductor)", "", # Cross-section is stated here, not left to be recalled. The model # was confidently pairing "4 AWG" with 8.4 mm2 - which is 8 AWG, a # 2.5x error in copper - because voltage_drop returned a gauge # number and nothing to anchor it to. f"Conductor: {body.awg} AWG = {area:.2f} mm² ({diameter:.2f} mm diameter), {ohms_km:.3f} Ω/km", f"Loop resistance: {eng(resistance, 'Ω')}", f"Power lost as heat: {eng(loss, 'W')}", ] supply = parse(body.voltage, "voltage") if body.voltage else None if supply and supply > 0: percent = drop / supply * 100 verdict = ("within the usual 3% guideline" if percent <= 3 else "above the usual 3% guideline — consider thicker wire or a shorter run") lines += ["", f"That is {percent:.2f}% of a {eng(supply, 'V')} supply — {verdict}.", f"Voltage at the load: {eng(supply - drop, 'V')}"] # Neighbouring gauges, so choosing one does not require five more calls # - and so the gauges NOT asked about are still real numbers rather # than remembered ones. neighbours = [g for g in range(body.awg - 3, body.awg + 4) if g != body.awg and -3 <= g <= 40] if neighbours: lines += ["", "## Nearby gauges, same run", ""] for gauge in neighbours: _, n_area, n_ohms = _awg_geometry(gauge) n_drop = current * (n_ohms / 1000 * length * 2) label = {0: "1/0", -1: "2/0", -2: "3/0", -3: "4/0"}.get(gauge, str(gauge)) row = f"- {label:>3} AWG ({n_area:6.2f} mm²): {eng(n_drop, 'V')}" if supply and supply > 0: row += f" = {n_drop / supply * 100:.2f}%" lines.append(row) lines.append("\nLower AWG numbers mean thicker wire and less drop.") lines.append(_MAINS_WARNING) return "\n".join(lines) if mode == "three_phase_drop": if body.awg is None: raise UnitError("awg is required for a three-phase voltage drop.") if body.power_factor is None: raise UnitError("power_factor is required — at low power factor the reactive term " "dominates, so a drop calculated without it is meaningless for " "motor starting.") volts, current = require({"voltage": body.voltage, "current": body.current}, "voltage", "current") length, length_shown = _cable_length_m(body.length) rx = _table9(body.awg, body.conduit) if rx is None: raise UnitError( f"{body.awg} AWG is not in NEC Chapter 9 Table 9 (tabulated 14 AWG through 4/0). " "Do not substitute an assumed reactance - say the size is outside the table.") r_km, x_km = rx pf = body.power_factor sin_phi = math.sqrt(max(0.0, 1 - pf * pf)) r_ohm = r_km / 1000 * length x_ohm = x_km / 1000 * length resistive = math.sqrt(3) * current * r_ohm * pf reactive = math.sqrt(3) * current * x_ohm * sin_phi drop = resistive + reactive pct = drop / volts * 100 if volts else 0.0 label = {0: "1/0", -1: "2/0", -2: "3/0", -3: "4/0"}.get(body.awg, str(body.awg)) lines = [ f"# 3-phase voltage drop — {label} AWG copper, {length_shown}, {current:g} A at PF {pf:g}", "", f"**Drop: {eng(drop, 'V')} = {pct:.2f}% of {eng(volts, 'V')}**", f"Voltage at the load: {eng(volts - drop, 'V')}", "", "## Where it comes from", f"NEC Ch.9 Table 9, {body.conduit} conduit, 75 °C, 60 Hz, three single conductors:", f" R = {r_km:.3f} Ω/km X = {x_km:.3f} Ω/km", f"Over {length:g} m: R = {eng(r_ohm, 'Ω')}, X = {eng(x_ohm, 'Ω')}", "", f"Vdrop = √3 × I × (R·cosφ + X·sinφ) with cosφ = {pf:g}, sinφ = {sin_phi:.3f}", f" resistive term: {eng(resistive, 'V')} ({resistive / drop * 100:.0f}% of the drop)", f" reactive term: {eng(reactive, 'V')} ({reactive / drop * 100:.0f}% of the drop)", ] if pf <= 0.5: lines += ["", "At this power factor the reactive term is a large share of the drop — a " "resistance-only calculation would understate it badly."] lines += ["", _MOTOR_START_CRITERIA] lines += [ "", "## What this does NOT tell you", "This is the drop across **this cable only**. Terminal voltage during a motor start " "also depends on the source and transformer impedance, which is usually the larger " "contributor. Do not declare a motor start acceptable from cable drop alone.", "", _CONDUCTOR_SIZING_NOTE, ] return "\n".join(lines) if mode == "led_resistor": supply, vf, current = require( {"voltage": body.voltage, "led_forward_voltage": body.led_forward_voltage, "led_current": body.led_current}, "voltage", "led_forward_voltage", "led_current") if vf >= supply: return (f"CANNOT DO THAT: the LED's forward voltage ({eng(vf, 'V')}) is not below the " f"supply ({eng(supply, 'V')}), so a series resistor cannot work. The user needs " "a higher supply voltage or a boost driver.") if current <= 0: raise UnitError("led_current must be greater than zero.") resistance = (supply - vf) / current dissipation = (supply - vf) * current nearest = _nearest_e_series(resistance, "E24").splitlines()[2] return "\n".join([ "# LED series resistor", "", f"**R = (Vsupply − Vf) / I = ({eng(supply, 'V')} − {eng(vf, 'V')}) / {eng(current, 'A')} " f"= {eng(resistance, 'Ω')}**", "", nearest.replace("**Closest standard value:", "Nearest E24 standard value:").replace("**", ""), "", f"Resistor dissipation: {eng(dissipation, 'W')} — a " f"{'standard 1/4 W part is fine' if dissipation < 0.125 else 'higher-wattage resistor is needed'}.", f"LED dissipation: {eng(vf * current, 'W')}", "", "Pick the next value *up* if you must choose, which runs the LED slightly dimmer and " "cooler. Driving several LEDs needs one resistor per LED, not one shared — LEDs do not " "share current evenly.", ]) if mode == "decibels": if body.value is None: raise UnitError("value is required for a decibel conversion.") value = parse(body.value, "value") source = body.db_from if source == "dBm": milliwatts = 10 ** (value / 10) elif source == "dB": return "\n".join([ f"# {value:g} dB", "", f"As a power ratio (10·log₁₀): ×{10 ** (value / 10):.4g}", f"As an amplitude ratio (20·log₁₀): ×{10 ** (value / 20):.4g}", "", "Plain dB is a *ratio*, not an absolute level — it needs a reference to become a " "power. Use the 10·log form for power and the 20·log form for voltage, current or " "any other amplitude; confusing the two is a factor-of-two error in dB.", ]) elif source == "watts": if value <= 0: raise UnitError("power must be greater than zero to express in dB.") milliwatts = value * 1000 elif source == "milliwatts": if value <= 0: raise UnitError("power must be greater than zero to express in dB.") milliwatts = value else: if value <= 0: raise UnitError("a ratio must be greater than zero to express in dB.") return "\n".join([ f"# Ratio ×{value:g}", "", f"As power: {10 * math.log10(value):.2f} dB", f"As amplitude (voltage/current): {20 * math.log10(value):.2f} dB", ]) dbm = 10 * math.log10(milliwatts) return "\n".join([ f"# {value:g} {source}", "", f"Power: {dbm:.2f} dBm", f" {dbm - 30:.2f} dBW", f" {eng(milliwatts / 1000, 'W')}", "", f"Into 50 Ω: {math.sqrt(milliwatts / 1000 * 50):.4g} V RMS", f"Into 75 Ω: {math.sqrt(milliwatts / 1000 * 75):.4g} V RMS", "", "Reference points: 0 dBm = 1 mW; +30 dBm = 1 W; every 3 dB doubles power, every " "10 dB is ten times.", ]) if mode == "battery_life": if body.capacity_mah is None or body.load_ma is None: raise UnitError("capacity_mah and load_ma are both required.") hours = body.capacity_mah / body.load_ma realistic = hours * 0.8 return "\n".join([ "# Battery life estimate", "", f"Capacity: {body.capacity_mah:g} mAh, average load: {body.load_ma:g} mA", "", f"**Ideal: {hours:.1f} hours ({hours / 24:.1f} days)**", f"Realistic (80% usable): {realistic:.1f} hours ({realistic / 24:.1f} days)", "", "The derating covers what the ideal figure ignores: capacity falls at higher discharge " "rates and in the cold, cells are not run to zero, and regulator quiescent current keeps " "drawing whether or not the load does. Treat this as an upper bound.", ]) raise UnitError(f"unknown calculation '{mode}'") # ------------------------------------------------------------ find_datasheet # Manufacturers with deterministic, keyless datasheet URLs. There is no free # component API - Octopart, Nexar, DigiKey and Mouser all require credentials - # so a known-good URL pattern is both the fastest and the most trustworthy # path when it applies. _VENDOR_PATTERNS = [ (re.compile(r"^(LM|TL|TPS|SN|CD|UA|NE|LMV|LMC|INA|OPA|REF|TLV|MSP|CC|ADS|DAC|UCC|TPD|SN74)\w*", re.I), # lit/gpn/{part} over lit/ds/symlink/{part}.pdf: both resolve for 7 of 8 # parts measured, but where the part has no symlink TI answers gpn with a # clean 404 while symlink silently 302s to a 144 KB product-category page. # A 404 lets this cascade fall through to the search path; the redirect # poisons it with the wrong document and no error. Measured 2026-09-10. "Texas Instruments", "https://www.ti.com/lit/gpn/{part}"), (re.compile(r"^(STM32|L\d{2,}|LD\d|TDA|VNH|ST[LP]|M24|M95)\w*", re.I), "STMicroelectronics", "https://www.st.com/resource/en/datasheet/{part}.pdf"), ] # Candidate labelling. Asked for an IRLZ44N, this tool returned IRLZ44, # IRLZ44N and IRFZ44N as an unannotated list under a hint that said to prefer # the manufacturer's own domain; the model dutifully picked a Vishay IRLZ44 - # a different die - over the Infineon IRLZ44N sitting one line below it, and # every rating it then quoted was for the wrong part. The suffix is the part, # so the list has to say which candidate is which. _ALNUM_RUN = re.compile(r"[A-Z0-9]+") _FAMILY_PREFIX = re.compile(r"^([A-Z]+\d+)") def _normalise_part(text: str) -> str: return re.sub(r"[^A-Z0-9]", "", text.upper()) def _family_prefix(token: str) -> str: """Leading letters and digits: the family an ordering suffix hangs off.""" match = _FAMILY_PREFIX.match(token) return match.group(1) if match else token def _variant_label(found: str, part: str) -> str: return (f"[VARIANT: names '{found}', you asked for '{part}' - a suffix difference can mean " "a different die, voltage class or package]") def _part_label(part: str, norm: str, text: str) -> str: """Label one candidate by how its part number relates to the one asked for.""" tokens = _ALNUM_RUN.findall(text.upper()) # Exactness is tested on whole tokens so that IRLZ44 does not count as a # hit inside IRLZ44N. Consecutive tokens are joined first because a title # may punctuate what the part number does not, e.g. "LM358-N" for LM358N. for start in range(len(tokens)): joined = "" for token in tokens[start:start + 4]: joined += token if joined == norm: return f"[EXACT {part}]" if len(joined) >= len(norm): break # Otherwise, whichever token in the title or URL looks most like a part # number of the same shape is the one this candidate is actually about. # Similarity rather than a shared prefix, because the pair that caused # this - IRLZ44N and IRFZ44N - differ in the third character and share # only "IR" up front. best, best_key = "", (0.0, 0) for token in tokens: if len(token) < 3 or not any(ch.isdigit() for ch in token): continue score = SequenceMatcher(None, token, norm).ratio() key = (round(score, 3), -abs(len(token) - len(norm))) if score >= 0.5 and key > best_key: best, best_key = token, key if not best: return "[DIFFERENT PART]" if _family_prefix(best) == _family_prefix(norm): return _variant_label(best, part) return f"[DIFFERENT PART: '{best}']" class DatasheetRequest(ToolModel): part_number: str = Field( description="Manufacturer part number, e.g. 'LM358', 'STM32F103C8T6', 'IRF540N', or an " "LCSC code like 'C25804'.", ) @router.post("/find_datasheet", operation_id="find_datasheet", response_model=str, summary="Find a component datasheet") @tool async def find_datasheet(body: DatasheetRequest) -> str: """Find datasheet PDF candidates for an electronic part by part number. Use it for any question about a specific part's pinout, ratings or characteristics - part numbers differ by one character between very different devices, so never answer from memory. Then fetch_page the best candidate and answer from what it says. If nothing is found, say so and ask the user to confirm the part number. """ part = body.part_number.strip() if not part: return "CANNOT DO THAT: provide a part number." if hit := await cache.cached("datasheet", part.lower()): return hit lines = [f"# Datasheet candidates for {part}", ""] found_any = False norm = _normalise_part(part) # Strip common package/reel/temperature suffixes before pattern matching: # TI publishes lm358.pdf, not lm358dr.pdf. base = re.sub(r"[-/]?(TR|T/R|DR|DGKR|PWR|NOPB|ND|E4|G4)$", "", part, flags=re.I) # A vendor URL is built from the part number itself and HEAD-verified, so # it is the part - unless the suffix strip above changed it, in which case # the document is for the base part and the caller should know that. vendor_label = (f"[EXACT {part}]" if _normalise_part(base) == norm else _variant_label(base, part)) for pattern, vendor, template in _VENDOR_PATTERNS: if pattern.match(base): url = template.format(part=base.lower()) try: client = get_client() head = await client.head(url, follow_redirects=True, timeout=12.0) if head.status_code == 200: lines += [f"{vendor_label} **{vendor} (official):** {url}", ""] found_any = True except Exception as exc: log.debug("vendor pattern probe failed for %s: %s", url, exc) if re.fullmatch(r"C\d{3,}", part, re.I): try: data = await get_json( f"https://easyeda.com/api/products/{part.upper()}/components", params={"version": "6.4.19.5"}) result = (data or {}).get("result") or {} if title := result.get("title"): szlcsc = result.get("szlcsc") or {} lines += [f"[EXACT {part}] **LCSC {part.upper()}:** {title}", f"{result.get('description', '')}", f"LCSC page: https://lcsc.com/product-detail/{szlcsc.get('number', part)}.html", ""] found_any = True except Exception as exc: log.debug("LCSC lookup failed for %s: %s", part, exc) # General search, restricted to PDFs. This is the fallback that covers # every manufacturer without a known URL pattern. try: response = await get_local_client().get( f"{settings.searxng_url}/search", params={"q": f"{part} datasheet filetype:pdf", "format": "json", "categories": "general"}, timeout=25.0) response.raise_for_status() results = response.json().get("results") or [] results.sort(key=lambda r: r.get("score") or 0, reverse=True) pdfs = [] for item in results: url = item.get("url") or "" if ".pdf" in url.lower() and url not in "\n".join(lines): title = item.get("title") or "untitled" pdfs.append(f"- {_part_label(part, norm, f'{title} {url}')} {title}\n {url}") if len(pdfs) >= 5: break if pdfs: lines += ["## Search results (PDF)", ""] + pdfs + [""] found_any = True except Exception as exc: log.info("datasheet search failed for %s: %s", part, exc) if not found_any: return (f"NO RESULTS. No datasheet was found for '{part}'. Ask the user to double-check " "the part number, or to name the manufacturer. Do not describe the part's " "specifications from memory.") lines.append("Next: read the [EXACT] candidate with fetch_page. An exact part number on an " "aggregator beats a variant on the manufacturer's own site - the suffix is the " "part. If only variants were found, say so and ask the user to confirm the exact " "part before quoting any rating.") result = "\n".join(lines) await cache.store("datasheet", result, part.lower()) return result # ------------------------------------------------------------- search_papers class PapersRequest(ToolModel): query: str = Field(description="Topic, e.g. 'GaN transistor thermal modelling'.") max_results: int = Field(default=4, ge=1, le=10, description="Papers to return, default 4, max 10.") open_access_only: bool = Field( default=True, description="Default true: only papers with a freely readable full text. Set false only " "for a complete survey of the literature.") @router.post("/search_papers", operation_id="search_papers", response_model=str, summary="Search academic papers") @tool async def search_papers(body: PapersRequest) -> str: """Search academic literature for engineering and science topics. Use it when a question needs research-grade sourcing - novel techniques, measured performance figures, comparisons between approaches - or when the user wants papers. Open-access results carry a PDF link readable with fetch_page. """ if hit := await cache.cached("papers", body.query, str(body.max_results), str(body.open_access_only)): return hit # OpenAlex rather than the arXiv API: arXiv's export endpoint rate-limits # this host's egress IP to a hard 429, verified repeatedly. OpenAlex # indexes arXiv anyway, allows 100k requests a day without a key, and the # mailto parameter buys the faster "polite pool". params = { "search": body.query, "per-page": min(body.max_results * 2, 20), "mailto": settings.contact, } if body.open_access_only: params["filter"] = "open_access.is_oa:true" data = await get_json("https://api.openalex.org/works", params=params, timeout=30.0) works = data.get("results") or [] if not works: return NO_RESULTS blocks: list[str] = [] any_full_text = False for work in works[:body.max_results]: title = work.get("title") or work.get("display_name") or "untitled" year = work.get("publication_year") or "?" citations = work.get("cited_by_count", 0) authors = [ (a.get("author") or {}).get("display_name", "") for a in (work.get("authorships") or [])[:4] ] author_line = ", ".join(a for a in authors if a) if len(work.get("authorships") or []) > 4: author_line += " et al." venue = ((work.get("primary_location") or {}).get("source") or {}).get("display_name", "") oa_url = (work.get("open_access") or {}).get("oa_url") or "" doi = work.get("doi") or "" block = [f"## {title}", f"{author_line} ({year}) · cited {citations} times"] if venue: block.append(f"Published in: {venue}") # A ready-to-quote citation line, built by the shared helper so that a # paper cited from this list and the same paper cited after fetch_page # read it come out worded identically. The line exists because the # model previously wrote "the TIME2 trial (NEJM 2022, n=148)" for a # study actually titled "Calorie Restriction with or without # Time-Restricted Eating in Weight Loss" with 139 participants - # correct finding, invented name and count. first = authors[0] if authors and authors[0] else "Unknown author" block.append("CITE AS: " + cite_line( first, title, venue, year, doi, et_al=len(work.get("authorships") or []) > 1)) if abstract := _rebuild_abstract(work.get("abstract_inverted_index")): block.append(f"\n{abstract[:700]}") if oa_url: block.append(f"\nFull text (PDF): {oa_url}") any_full_text = True blocks.append("\n".join(block)) # The "Next: fetch a PDF" line used to print even when not one result had # an open-access link, which asks for something that cannot be done and # leaves the abstracts looking like a readable paper. trailer = ("\n\nNext: call fetch_page on a full-text PDF link to read the paper itself before " "summarising its findings." if any_full_text else "\n\nNo open-access full text was found among these. You may quote the CITE AS " "lines, but you cannot report findings from inside these papers - only what the " "abstracts above actually say.") result = ("\n\n---\n\n".join(blocks) + "\n\nQuote the CITE AS lines exactly. They are the whole citation available: if a " "study has a trial acronym, a registration number or a participant count, it is NOT " "in this data - read the paper with fetch_page before stating one, or describe the " "study without naming it." + trailer) await cache.store("papers", result, body.query, str(body.max_results), str(body.open_access_only)) return result def _rebuild_abstract(inverted) -> str: """OpenAlex stores abstracts as {word: [positions]} for copyright reasons.""" if not isinstance(inverted, dict) or not inverted: return "" positions: list[tuple[int, str]] = [] for word, indices in inverted.items(): for index in indices or []: positions.append((index, word)) positions.sort() return " ".join(word for _, word in positions)