mcltspice/src/mcp_ltspice/optimizer.py
Ryan Malloy ba649d2a6e Add stability, power, optimization, batch, and schematic generation tools
Phase 3 features bringing the server to 27 tools:
- Stepped/multi-run .raw file parsing (.step, .mc, .temp)
- Stability analysis (gain/phase margin from AC loop gain)
- Power analysis (average, RMS, efficiency, power factor)
- Safe waveform expression evaluator (recursive-descent parser)
- Component value optimizer (binary search + coordinate descent)
- Batch simulation: parameter sweep, temperature sweep, Monte Carlo
- .asc schematic generation from templates (RC filter, divider, inverting amp)
- Touchstone .s1p/.s2p/.snp S-parameter file parsing
- 7 new netlist templates (diff amp, common emitter, buck, LDO, oscillator, H-bridge)
- Full ruff lint and format compliance across all modules
2026-02-10 23:05:35 -07:00

810 lines
23 KiB
Python

"""Component value optimizer for iterative circuit tuning.
Adjusts circuit parameters toward target specifications by running
real LTspice simulations in a loop. Supports single-component binary
search and multi-component coordinate descent.
"""
import logging
import math
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
from .raw_parser import RawFile
from .runner import run_netlist
from .waveform_math import (
compute_bandwidth,
compute_peak_to_peak,
compute_rms,
compute_settling_time,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Standard component value series
# ---------------------------------------------------------------------------
# E12: 12 values per decade (10% tolerance)
_E12 = [1.0, 1.2, 1.5, 1.8, 2.2, 2.7, 3.3, 3.9, 4.7, 5.6, 6.8, 8.2]
# E24: 24 values per decade (5% tolerance)
_E24 = [
1.0,
1.1,
1.2,
1.3,
1.5,
1.6,
1.8,
2.0,
2.2,
2.4,
2.7,
3.0,
3.3,
3.6,
3.9,
4.3,
4.7,
5.1,
5.6,
6.2,
6.8,
7.5,
8.2,
9.1,
]
# E96: 96 values per decade (1% tolerance)
_E96 = [
1.00,
1.02,
1.05,
1.07,
1.10,
1.13,
1.15,
1.18,
1.21,
1.24,
1.27,
1.30,
1.33,
1.37,
1.40,
1.43,
1.47,
1.50,
1.54,
1.58,
1.62,
1.65,
1.69,
1.74,
1.78,
1.82,
1.87,
1.91,
1.96,
2.00,
2.05,
2.10,
2.15,
2.21,
2.26,
2.32,
2.37,
2.43,
2.49,
2.55,
2.61,
2.67,
2.74,
2.80,
2.87,
2.94,
3.01,
3.09,
3.16,
3.24,
3.32,
3.40,
3.48,
3.57,
3.65,
3.74,
3.83,
3.92,
4.02,
4.12,
4.22,
4.32,
4.42,
4.53,
4.64,
4.75,
4.87,
4.99,
5.11,
5.23,
5.36,
5.49,
5.62,
5.76,
5.90,
6.04,
6.19,
6.34,
6.49,
6.65,
6.81,
6.98,
7.15,
7.32,
7.50,
7.68,
7.87,
8.06,
8.25,
8.45,
8.66,
8.87,
9.09,
9.31,
9.53,
9.76,
]
_SERIES = {
"E12": _E12,
"E24": _E24,
"E96": _E96,
}
# Engineering prefixes: exponent -> suffix
_ENG_PREFIXES = {
-15: "f",
-12: "p",
-9: "n",
-6: "u",
-3: "m",
0: "",
3: "k",
6: "M",
9: "G",
12: "T",
}
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class OptimizationTarget:
"""A single performance target to optimize toward."""
signal_name: str # e.g. "V(out)"
metric: str # "bandwidth_hz", "rms", "peak_to_peak", "settling_time",
# "gain_db", "phase_margin_deg"
target_value: float
weight: float = 1.0
@dataclass
class ComponentRange:
"""Allowed range for a component value during optimization."""
component_name: str # e.g. "R1"
min_value: float
max_value: float
preferred_series: str | None = None # "E12", "E24", "E96"
@dataclass
class OptimizationResult:
"""Outcome of an optimization run."""
best_values: dict[str, float]
best_cost: float
iterations: int
history: list[dict] = field(default_factory=list)
targets_met: dict[str, bool] = field(default_factory=dict)
final_metrics: dict[str, float] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def snap_to_preferred(value: float, series: str = "E12") -> float:
"""Snap a value to the nearest standard component value.
Works across decades -- e.g. 4800 snaps to 4.7k (E12).
Args:
value: Raw component value in base units (ohms, farads, etc.)
series: Preferred series name ("E12", "E24", "E96")
Returns:
Nearest standard value from the requested series.
"""
if value <= 0:
return _SERIES.get(series, _E12)[0]
base_values = _SERIES.get(series, _E12)
decade = math.floor(math.log10(value))
mantissa = value / (10**decade)
best = base_values[0]
best_ratio = abs(math.log10(mantissa / best))
for bv in base_values[1:]:
ratio = abs(math.log10(mantissa / bv))
if ratio < best_ratio:
best = bv
best_ratio = ratio
# Also check the decade above (mantissa might round up past 9.x)
for bv in base_values[:2]:
candidate = bv * 10
ratio = abs(math.log10(mantissa / candidate))
if ratio < best_ratio:
best = candidate
best_ratio = ratio
return best * (10**decade)
def format_engineering(value: float) -> str:
"""Format a float in engineering notation with SI suffix.
Examples:
10000 -> "10k"
0.0001 -> "100u"
4700 -> "4.7k"
0.0000033 -> "3.3u"
1.5 -> "1.5"
"""
if value == 0:
return "0"
sign = "-" if value < 0 else ""
value = abs(value)
exp = math.floor(math.log10(value))
# Round to nearest multiple of 3 (downward)
eng_exp = (exp // 3) * 3
# Clamp to known prefixes
eng_exp = max(-15, min(12, eng_exp))
mantissa = value / (10**eng_exp)
suffix = _ENG_PREFIXES.get(eng_exp, f"e{eng_exp}")
# Format mantissa: strip trailing zeros but keep at least one digit
if mantissa == int(mantissa) and mantissa < 1000:
formatted = f"{sign}{int(mantissa)}{suffix}"
else:
formatted = f"{sign}{mantissa:.3g}{suffix}"
return formatted
# ---------------------------------------------------------------------------
# Metric extraction
# ---------------------------------------------------------------------------
def _extract_metric(
raw_data: RawFile,
target: OptimizationTarget,
) -> float | None:
"""Compute a single metric from simulation results.
Returns the measured value, or None if the signal/data is unavailable.
"""
signal = raw_data.get_variable(target.signal_name)
if signal is None:
return None
metric = target.metric
if metric == "rms":
return compute_rms(signal)
if metric == "peak_to_peak":
result = compute_peak_to_peak(signal)
return result["peak_to_peak"]
if metric == "settling_time":
time = raw_data.get_time()
if time is None:
return None
if np.iscomplexobj(time):
time = time.real
if np.iscomplexobj(signal):
signal = np.abs(signal)
result = compute_settling_time(time, signal)
return result["settling_time"]
if metric == "bandwidth_hz":
freq = raw_data.get_frequency()
if freq is None:
return None
# Convert complex magnitude to dB
mag_db = np.where(
np.abs(signal) > 0,
20.0 * np.log10(np.abs(signal)),
-200.0,
)
result = compute_bandwidth(np.real(freq), np.real(mag_db))
return result["bandwidth_hz"]
if metric == "gain_db":
# Peak gain in dB (for AC analysis signals)
mag = np.abs(signal)
peak = float(np.max(mag))
if peak > 0:
return 20.0 * math.log10(peak)
return -200.0
if metric == "phase_margin_deg":
freq = raw_data.get_frequency()
if freq is None:
return None
# Phase margin: phase at the frequency where |gain| crosses 0 dB
mag = np.abs(signal)
mag_db = np.where(mag > 0, 20.0 * np.log10(mag), -200.0)
phase_deg = np.degrees(np.angle(signal))
# Find 0 dB crossing (gain crossover)
for i in range(len(mag_db) - 1):
if mag_db[i] >= 0 and mag_db[i + 1] < 0:
# Interpolate phase at 0 dB crossing
dm = mag_db[i + 1] - mag_db[i]
if abs(dm) < 1e-30:
phase_at_xover = float(phase_deg[i])
else:
frac = (0.0 - mag_db[i]) / dm
phase_at_xover = float(phase_deg[i] + frac * (phase_deg[i + 1] - phase_deg[i]))
# Phase margin = 180 + phase (since we want distance from -180)
return 180.0 + phase_at_xover
# No 0 dB crossing found -- gain never reaches unity
return None
return None
def _compute_cost(
raw_data: RawFile,
targets: list[OptimizationTarget],
) -> tuple[float, dict[str, float]]:
"""Evaluate weighted cost across all targets.
Cost for each target: weight * |measured - target| / |target|
Uses |target| normalization so different units are comparable.
Returns:
(total_cost, metrics_dict) where metrics_dict maps
"signal_name.metric" -> measured_value
"""
total_cost = 0.0
metrics: dict[str, float] = {}
for t in targets:
measured = _extract_metric(raw_data, t)
key = f"{t.signal_name}.{t.metric}"
if measured is None:
# Signal not found -- heavy penalty
total_cost += t.weight * 1e6
metrics[key] = float("nan")
continue
metrics[key] = measured
if abs(t.target_value) > 1e-30:
cost = t.weight * abs(measured - t.target_value) / abs(t.target_value)
else:
# Target is ~0: use absolute error
cost = t.weight * abs(measured)
total_cost += cost
return total_cost, metrics
# ---------------------------------------------------------------------------
# Core optimizer
# ---------------------------------------------------------------------------
async def _run_with_values(
netlist_template: str,
values: dict[str, float],
work_dir: Path,
) -> RawFile | None:
"""Substitute values into template, write .cir, simulate, return parsed data."""
text = netlist_template
for name, val in values.items():
text = text.replace(f"{{{name}}}", format_engineering(val))
cir_path = work_dir / "opt_iter.cir"
cir_path.write_text(text)
result = await run_netlist(cir_path, work_dir=work_dir)
if not result.success or result.raw_data is None:
logger.warning("Simulation failed: %s", result.error)
return None
return result.raw_data
async def _binary_search_single(
netlist_template: str,
target: OptimizationTarget,
comp: ComponentRange,
max_iterations: int,
work_dir: Path,
) -> OptimizationResult:
"""Optimize a single component via binary search.
Assumes the metric is monotonic (or at least locally monotonic)
with respect to the component value. Evaluates the midpoint,
then narrows the half that moves the metric toward the target.
"""
lo = comp.min_value
hi = comp.max_value
history: list[dict] = []
best_values = {comp.component_name: (lo + hi) / 2}
best_cost = float("inf")
best_metrics: dict[str, float] = {}
for iteration in range(max_iterations):
mid = (lo + hi) / 2
values = {comp.component_name: mid}
raw_data = await _run_with_values(netlist_template, values, work_dir)
if raw_data is None:
history.append(
{
"iteration": iteration,
"values": {comp.component_name: mid},
"cost": float("inf"),
"error": "simulation_failed",
}
)
# Narrow toward the other half on failure
hi = mid
continue
cost, metrics = _compute_cost(raw_data, [target])
measured = metrics.get(f"{target.signal_name}.{target.metric}")
history.append(
{
"iteration": iteration,
"values": {comp.component_name: mid},
"cost": cost,
"metrics": metrics,
}
)
if cost < best_cost:
best_cost = cost
best_values = {comp.component_name: mid}
best_metrics = metrics
# Decide which half to keep
if measured is not None and not math.isnan(measured):
if measured < target.target_value:
# Need larger metric -- which direction depends on monotonicity.
# Test: does increasing component value increase or decrease metric?
# We probe a point slightly above mid to determine direction.
probe = mid * 1.1
if probe > hi:
probe = mid * 0.9
probe_is_lower = True
else:
probe_is_lower = False
probe_data = await _run_with_values(
netlist_template, {comp.component_name: probe}, work_dir
)
if probe_data is not None:
probe_measured = _extract_metric(probe_data, target)
if probe_measured is not None:
if probe_is_lower:
# We probed lower. If metric went up, metric increases
# with decreasing value => go lower.
if probe_measured > measured:
hi = mid
else:
lo = mid
else:
# We probed higher. If metric went up, go higher.
if probe_measured > measured:
lo = mid
else:
hi = mid
else:
hi = mid
else:
hi = mid
elif measured > target.target_value:
# Need smaller metric -- mirror logic
probe = mid * 1.1
if probe > hi:
probe = mid * 0.9
probe_is_lower = True
else:
probe_is_lower = False
probe_data = await _run_with_values(
netlist_template, {comp.component_name: probe}, work_dir
)
if probe_data is not None:
probe_measured = _extract_metric(probe_data, target)
if probe_measured is not None:
if probe_is_lower:
if probe_measured < measured:
hi = mid
else:
lo = mid
else:
if probe_measured < measured:
lo = mid
else:
hi = mid
else:
lo = mid
else:
lo = mid
else:
# Exact match
break
# Converged if search range is tight enough
if hi - lo < lo * 0.001:
break
# Snap to preferred series if requested
final_val = best_values[comp.component_name]
if comp.preferred_series:
final_val = snap_to_preferred(final_val, comp.preferred_series)
best_values[comp.component_name] = final_val
target_key = f"{target.signal_name}.{target.metric}"
met = best_metrics.get(target_key)
tolerance = abs(target.target_value * 0.05) if abs(target.target_value) > 0 else 0.05
targets_met = {
target_key: met is not None and abs(met - target.target_value) <= tolerance,
}
return OptimizationResult(
best_values=best_values,
best_cost=best_cost,
iterations=len(history),
history=history,
targets_met=targets_met,
final_metrics=best_metrics,
)
async def _coordinate_descent(
netlist_template: str,
targets: list[OptimizationTarget],
component_ranges: list[ComponentRange],
max_iterations: int,
work_dir: Path,
) -> OptimizationResult:
"""Optimize multiple components via coordinate descent.
Cycles through components, running a bounded golden-section search
on each one while holding the others fixed. Repeats until the
overall cost stops improving or we exhaust the iteration budget.
"""
# Start at geometric midpoint of each range
current_values: dict[str, float] = {}
for cr in component_ranges:
current_values[cr.component_name] = math.sqrt(cr.min_value * cr.max_value)
history: list[dict] = []
best_cost = float("inf")
best_values = dict(current_values)
best_metrics: dict[str, float] = {}
iteration = 0
n_comps = len(component_ranges)
# Golden ratio for search
phi = (1 + math.sqrt(5)) / 2
resphi = 2 - phi # ~0.382
while iteration < max_iterations:
improved_this_cycle = False
for cr in component_ranges:
if iteration >= max_iterations:
break
lo = cr.min_value
hi = cr.max_value
# Golden-section search for this component
# Allocate a few iterations per component per cycle
iters_per_comp = max(2, (max_iterations - iteration) // n_comps)
a = lo
b = hi
x1 = a + resphi * (b - a)
x2 = b - resphi * (b - a)
# Evaluate x1
trial_1 = dict(current_values)
trial_1[cr.component_name] = x1
raw_1 = await _run_with_values(netlist_template, trial_1, work_dir)
cost_1 = float("inf")
metrics_1: dict[str, float] = {}
if raw_1 is not None:
cost_1, metrics_1 = _compute_cost(raw_1, targets)
iteration += 1
# Evaluate x2
trial_2 = dict(current_values)
trial_2[cr.component_name] = x2
raw_2 = await _run_with_values(netlist_template, trial_2, work_dir)
cost_2 = float("inf")
metrics_2: dict[str, float] = {}
if raw_2 is not None:
cost_2, metrics_2 = _compute_cost(raw_2, targets)
iteration += 1
for _ in range(iters_per_comp - 2):
if iteration >= max_iterations:
break
if cost_1 < cost_2:
b = x2
x2 = x1
cost_2 = cost_1
metrics_2 = metrics_1
x1 = a + resphi * (b - a)
trial = dict(current_values)
trial[cr.component_name] = x1
raw = await _run_with_values(netlist_template, trial, work_dir)
if raw is not None:
cost_1, metrics_1 = _compute_cost(raw, targets)
else:
cost_1 = float("inf")
metrics_1 = {}
else:
a = x1
x1 = x2
cost_1 = cost_2
metrics_1 = metrics_2
x2 = b - resphi * (b - a)
trial = dict(current_values)
trial[cr.component_name] = x2
raw = await _run_with_values(netlist_template, trial, work_dir)
if raw is not None:
cost_2, metrics_2 = _compute_cost(raw, targets)
else:
cost_2 = float("inf")
metrics_2 = {}
iteration += 1
# Pick the better of the final two
if cost_1 <= cost_2:
chosen_val = x1
chosen_cost = cost_1
chosen_metrics = metrics_1
else:
chosen_val = x2
chosen_cost = cost_2
chosen_metrics = metrics_2
# Snap to preferred series
if cr.preferred_series:
chosen_val = snap_to_preferred(chosen_val, cr.preferred_series)
current_values[cr.component_name] = chosen_val
history.append(
{
"iteration": iteration,
"optimized_component": cr.component_name,
"values": dict(current_values),
"cost": chosen_cost,
"metrics": chosen_metrics,
}
)
if chosen_cost < best_cost:
best_cost = chosen_cost
best_values = dict(current_values)
best_metrics = chosen_metrics
improved_this_cycle = True
if not improved_this_cycle:
break
# Determine which targets are met (within 5%)
targets_met: dict[str, bool] = {}
for t in targets:
key = f"{t.signal_name}.{t.metric}"
measured = best_metrics.get(key)
if measured is None or math.isnan(measured):
targets_met[key] = False
else:
tolerance = abs(t.target_value * 0.05) if abs(t.target_value) > 0 else 0.05
targets_met[key] = abs(measured - t.target_value) <= tolerance
return OptimizationResult(
best_values=best_values,
best_cost=best_cost,
iterations=iteration,
history=history,
targets_met=targets_met,
final_metrics=best_metrics,
)
async def optimize_component_values(
netlist_template: str,
targets: list[OptimizationTarget],
component_ranges: list[ComponentRange],
max_iterations: int = 20,
method: str = "binary_search",
) -> OptimizationResult:
"""Iteratively adjust component values toward target specifications.
Creates temporary .cir files, runs real LTspice simulations via Wine,
and evaluates a cost function against the targets after each run.
Args:
netlist_template: Netlist text with ``{ComponentName}`` placeholders
(e.g. ``{R1}``, ``{C1}``) that get substituted each iteration.
targets: Performance targets to optimize toward.
component_ranges: Allowed ranges for each tunable component.
max_iterations: Maximum simulation iterations (default 20).
method: ``"binary_search"`` for single-component optimization,
``"coordinate_descent"`` for multi-component. When
``"binary_search"`` is requested with multiple components,
coordinate descent is used automatically.
Returns:
OptimizationResult with best values found, cost history,
and whether each target was met.
"""
work_dir = Path(tempfile.mkdtemp(prefix="ltspice_opt_"))
if len(component_ranges) == 1 and len(targets) == 1 and method == "binary_search":
return await _binary_search_single(
netlist_template,
targets[0],
component_ranges[0],
max_iterations,
work_dir,
)
return await _coordinate_descent(
netlist_template,
targets,
component_ranges,
max_iterations,
work_dir,
)