1759 lines
84 KiB
Python
1759 lines
84 KiB
Python
"""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)
|