Files
owui-tools/app/owuitools/routers/ee.py
T
2026-09-15 15:24:03 -04:00

1759 lines
84 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)