Source code for reasitic.inductance.partial

"""Greenhouse partial-inductance summation.

Total inductance of a planar coil is the sum of the per-segment
self-inductances plus all signed pairwise mutual inductances:

.. math::

    L_\\text{total} = \\sum_i L_i + \\sum_{i \\neq j} \\sigma_{ij} M_{ij}

The sign ``σ_ij`` is +1 when the two segments carry current in the
same direction along their common axis, -1 when they oppose, and 0
when they are perpendicular (parallel-segment Grover formula yields
zero in that case anyway). Reference: H. M. Greenhouse, "Design of
Planar Rectangular Microelectronic Inductors," IEEE Trans. PHP-10
(1974).

The per-pair dispatch mirrors the binary's ``check_segments_intersect``
(decomp ``0x08061110``) and ``mutual_inductance_3d_segments``
(``0x08062ebc``):

* perpendicular pairs (``|û_a · û_b| < 1e-10``) → 0
* axis-aligned parallel pairs → closed-form Grover 4-corner via
  :func:`reasitic.inductance.grover.parallel_segment_mutual`
* general parallel-not-axis-aligned and skew pairs → Maxwell double
  integral via :func:`reasitic.inductance.skew.mutual_inductance_skew_segments`

The skew kernel internally short-circuits exactly-parallel pairs to
its own closed form, so the only pairs that hit the numerical
integrator are genuinely non-parallel ones (typical only for
polygon spirals and 3D / multi-metal structures).
"""

from __future__ import annotations

import math

from reasitic.geometry import Point, Segment, Shape
from reasitic.inductance.grover import (
    parallel_segment_mutual,
    rectangular_bar_self_inductance,
)
from reasitic.inductance.skew import mutual_inductance_skew_segments


def _axis_of(seg: Segment) -> tuple[int, float]:
    """Return ``(axis, sign)`` for an axis-aligned segment.

    ``axis`` is 0/1/2 for x/y/z; ``sign`` is +1 or -1 depending on
    whether ``b`` is greater or less than ``a`` along that axis.
    Raises ``ValueError`` for non-axis-aligned segments.
    """
    dx = seg.b.x - seg.a.x
    dy = seg.b.y - seg.a.y
    dz = seg.b.z - seg.a.z
    nonzero = sum(abs(v) > 1e-9 for v in (dx, dy, dz))
    if nonzero == 0:
        raise ValueError("zero-length segment")
    if nonzero != 1:
        raise ValueError("segment is not axis-aligned")
    if abs(dx) > 1e-9:
        return 0, math.copysign(1.0, dx)
    if abs(dy) > 1e-9:
        return 1, math.copysign(1.0, dy)
    return 2, math.copysign(1.0, dz)




def _parallel_axis_pair(
    a: Segment, b: Segment, axis: int
) -> tuple[float, float, float, float, float] | None:
    """Project two segments onto a shared axis.

    Returns ``(L1, L2, sep, offset, sign)`` ready to feed
    :func:`parallel_segment_mutual`, or ``None`` if the segments are
    not parallel on the same axis. ``sign`` is +1 when both segments
    run in the same direction along ``axis``, -1 when opposite.
    """
    try:
        ax_a, sgn_a = _axis_of(a)
        ax_b, sgn_b = _axis_of(b)
    except ValueError:
        return None
    if ax_a != axis or ax_b != axis:
        return None

    pa0 = _coord(a.a, axis)
    pa1 = _coord(a.b, axis)
    pb0 = _coord(b.a, axis)
    pb1 = _coord(b.b, axis)

    # Re-orient so each segment runs in +axis direction
    if sgn_a < 0:
        pa0, pa1 = pa1, pa0
    if sgn_b < 0:
        pb0, pb1 = pb1, pb0

    L1 = pa1 - pa0
    L2 = pb1 - pb0

    # Perpendicular separation: sqrt of squared distances on the
    # other two axes (both endpoints of A are at the same off-axis
    # coordinate by axis-aligned construction)
    other_axes = [i for i in range(3) if i != axis]
    sep_sq = 0.0
    for i in other_axes:
        ca = _coord(a.a, i)
        cb = _coord(b.a, i)
        sep_sq += (ca - cb) ** 2
    sep = math.sqrt(sep_sq)

    # Offset from start of A to start of B, measured along axis
    offset = pb0 - pa0

    sign = sgn_a * sgn_b
    return (L1, L2, sep, offset, sign)


def _coord(p: Point, axis: int) -> float:
    return p.x if axis == 0 else (p.y if axis == 1 else p.z)


_PERP_DOT_TOL = 1e-10


def _segment_pair_mutual(a: Segment, b: Segment) -> float:
    """Signed mutual-inductance contribution from one segment pair.

    Mirrors the C dispatch in ``check_segments_intersect``
    (``asitic_kernel.c:3882``) followed by the geometry classifier
    in ``mutual_inductance_3d_segments`` (``:4875``):

    1. If ``|û_a · û_b| < 1e-10`` (perpendicular), contribute 0.
       The C path early-returns before classification.
    2. Try the axis-aligned-parallel closed form first. Manhattan
       spirals — which are the bulk of ASITIC use — flow entirely
       through this fast path.
    3. Otherwise delegate to :func:`mutual_inductance_skew_segments`
       which handles parallel-non-axis-aligned (closed form) and
       general skew (Maxwell double integral).

    Co-linear segments (zero perpendicular separation) are suppressed
    to match the binary's intra-shape chain handling.
    """
    dx_a = a.b.x - a.a.x
    dy_a = a.b.y - a.a.y
    dz_a = a.b.z - a.a.z
    dx_b = b.b.x - b.a.x
    dy_b = b.b.y - b.a.y
    dz_b = b.b.z - b.a.z
    L_a_sq = dx_a * dx_a + dy_a * dy_a + dz_a * dz_a
    L_b_sq = dx_b * dx_b + dy_b * dy_b + dz_b * dz_b
    if L_a_sq < 1e-24 or L_b_sq < 1e-24:
        return 0.0
    L_a = math.sqrt(L_a_sq)
    L_b = math.sqrt(L_b_sq)
    cos_alpha = (dx_a * dx_b + dy_a * dy_b + dz_a * dz_b) / (L_a * L_b)
    if abs(cos_alpha) < _PERP_DOT_TOL:
        return 0.0

    try:
        ax_a, _ = _axis_of(a)
    except ValueError:
        ax_a = -1
    pair = _parallel_axis_pair(a, b, ax_a) if ax_a >= 0 else None
    if pair is not None:
        L1, L2, sep, offset, sign = pair
        if sep < 1e-12:
            # Co-linear continuation; suppressed.
            return 0.0
        return sign * parallel_segment_mutual(L1, L2, sep, offset)

    # General path: parallel-non-axis-aligned + skew.
    return mutual_inductance_skew_segments(a.a, a.b, b.a, b.b)


[docs] def compute_self_inductance(shape: Shape) -> float: """Total self-inductance (in nH) of a shape. Uses Greenhouse partial-inductance summation. Mutual terms are routed through :func:`_segment_pair_mutual`, which dispatches to the axis-aligned-parallel closed form for Manhattan spirals and to the general skew kernel for polygon / 3D geometry. """ segs = shape.segments() if not segs: return 0.0 L = 0.0 # Self terms — use the same rectangular-bar formula as the binary. for s in segs: if s.length <= 0: continue L += rectangular_bar_self_inductance(s.length, s.width, s.thickness) # Mutual terms — axis-aligned only for now. n = len(segs) for i in range(n): for j in range(i + 1, n): L += 2.0 * _segment_pair_mutual(segs[i], segs[j]) return L
[docs] def compute_mutual_inductance(shape_a: Shape, shape_b: Shape) -> float: """Mutual inductance between two distinct shapes, in nH. Sums the signed Greenhouse mutual contribution over every cross-pair of segments. The double-counting factor of 2 used in the self case does **not** apply here because each (i, j) pair appears exactly once. Mirrors the binary's ``cmd_coupling_compute`` (``asitic_repl.c:1478``) — parallel, perpendicular, and general skew geometries are all handled via :func:`_segment_pair_mutual`. """ M = 0.0 for sa in shape_a.segments(): for sb in shape_b.segments(): M += _segment_pair_mutual(sa, sb) return M
[docs] def coupling_coefficient(shape_a: Shape, shape_b: Shape) -> float: """Magnetic coupling coefficient ``k = M / sqrt(L₁·L₂)``. Returns 0 if either self-inductance is non-positive. The result is dimensionless and lies in (-1, +1) for physically realisable geometries. """ M = compute_mutual_inductance(shape_a, shape_b) L1 = compute_self_inductance(shape_a) L2 = compute_self_inductance(shape_b) if L1 <= 0 or L2 <= 0: return 0.0 return M / math.sqrt(L1 * L2)