Source code for reasitic.optimise.opt_sq

"""``OptSq`` — square-spiral inductor optimisation.

Mirrors the binary's ``cmd_opt_l_sq`` (case 700 in
``commands.json``): given a target inductance and operating
frequency, search over (length, turns, width, spacing) for the
geometry that maximises Q while meeting the L target. We use
``scipy.optimize.minimize`` with the SLSQP algorithm for
constrained optimisation.

The original ASITIC's optimiser is a hand-rolled gradient descent
with finite differences (``set_cell_size_normal`` /
``compute_inductance`` re-evaluated each step). scipy.optimize's
SLSQP is more robust, supports multiple constraints, and gives us
quasi-Newton convergence for free.
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import scipy.optimize

from reasitic import (
    Shape,
    compute_self_inductance,
    metal_only_q,
    square_spiral,
)
from reasitic.tech import Tech


[docs] @dataclass class OptResult: """One optimisation result.""" success: bool length_um: float width_um: float spacing_um: float turns: float L_nH: float Q: float n_iter: int message: str
[docs] def optimise_square_spiral( tech: Tech, *, target_L_nH: float, freq_ghz: float, metal: int | str = 0, length_bounds: tuple[float, float] = (50.0, 500.0), width_bounds: tuple[float, float] = (2.0, 30.0), spacing_bounds: tuple[float, float] = (1.0, 10.0), turns_bounds: tuple[float, float] = (1.0, 10.0), init: tuple[float, float, float, float] | None = None, L_tolerance: float = 0.05, ) -> OptResult: """Maximise Q for a square spiral subject to L = ``target_L_nH``. ``init`` is an optional ``(length, width, spacing, turns)`` starting point; if omitted, the centre of each parameter's bounds is used. Returns an :class:`OptResult` with the best geometry plus its computed L and Q. The constraint ``|L - target| / target ≤ L_tolerance`` is enforced via SLSQP inequality constraints. """ def _build(x: np.ndarray) -> Shape: length, width, spacing, turns = (float(v) for v in x) return square_spiral( "_opt", length=length, width=width, spacing=spacing, turns=turns, tech=tech, metal=metal, ) def _q_neg(x: np.ndarray) -> float: try: sh = _build(x) q = metal_only_q(sh, tech, freq_ghz) except (ValueError, ZeroDivisionError): return 1e6 return -q # minimise -Q → maximise Q def _l_lower(x: np.ndarray) -> float: sh = _build(x) return compute_self_inductance(sh) - target_L_nH * (1.0 - L_tolerance) def _l_upper(x: np.ndarray) -> float: sh = _build(x) return target_L_nH * (1.0 + L_tolerance) - compute_self_inductance(sh) bounds = [length_bounds, width_bounds, spacing_bounds, turns_bounds] if init is None: x0 = np.array([0.5 * (lo + hi) for (lo, hi) in bounds]) else: x0 = np.array(init, dtype=float) result = scipy.optimize.minimize( _q_neg, x0, method="SLSQP", bounds=bounds, constraints=[ {"type": "ineq", "fun": _l_lower}, {"type": "ineq", "fun": _l_upper}, ], options={"maxiter": 100, "ftol": 1e-4}, ) sh = _build(result.x) L = compute_self_inductance(sh) Q = metal_only_q(sh, tech, freq_ghz) return OptResult( success=bool(result.success), length_um=float(result.x[0]), width_um=float(result.x[1]), spacing_um=float(result.x[2]), turns=float(result.x[3]), L_nH=L, Q=Q, n_iter=int(getattr(result, "nit", 0)), message=str(result.message), )