Source code for circuitgenome.synthesizer.compatibility.compensation

"""
Compensation inversion-parity compatibility filter for
:func:`~circuitgenome.synthesizer.synthesizer.enumerate_circuits`.

Every ``compensation`` variant in the library is Miller-family: it couples
its ``in`` port to its ``out`` port through a capacitor (directly, through a
nulling resistor, or via an internal series node). Wired across a stage
chain, that coupling is *negative* feedback -- pole splitting -- only when
the wrapped chain is inverting. Around a non-inverting chain with gain the
same capacitor is *positive* feedback: the AC response develops a
right-half-plane character and gain/GBW/PM become unmeasurable (issue #114:
``differential_ota_second_stage``, two cascaded common-source stages, PM
measured 270-281 deg with every compensation variant).

The parity of a chain is the number of common-source inversions along its
``in -> out`` device path:

- a **common-source hop** (signal enters a gate, exits the drain) inverts;
- a **follower hop** (signal enters a gate, exits the source) does not
  invert -- and contributes no gain, so a Miller capacitor around a pure
  follower chain is bootstrapped to ``C*(1-A) ~ 0``: useless but benign,
  NOT positive feedback. Followers are therefore deliberately allowed
  (a strict odd-parity rule would ban them and undo issue #110).

The reject condition is a chain whose total inversion count is a **positive
even number** -- non-inverting *with* gain. This also covers composite
chains: in the nested-Miller 3-stage topologies ``comp1`` wraps the
second+third stage cascade (``net_mid1 -> out``), where two common-source
stages compose to the same positive-feedback structure (standard NMC
requires a non-inverting second stage and an inverting output stage).

The check is *structural* (actual device terminal references, no YAML
tags, same approach as :mod:`~circuitgenome.synthesizer.compatibility.second_stage`):
a stage variant's inversion count is computed by walking gate-to-drain /
gate-to-source hops from its ``in`` port, and chains are composed by
following ``second_stage``-category slots' ``in``/``out`` nets between the
compensation slot's ``in`` and ``out`` nets. Anything unclassifiable (a
compensation variant that does not couple ``in`` to ``out``, a chain the
walk cannot follow, a stage with no device gating ``in``) imposes no
constraint.
"""
from __future__ import annotations
from ..models import ModuleVariant, TopologyTemplate

_MOS_TYPES = ("nmos", "pmos")


def _couples_in_out(variant: ModuleVariant) -> bool:
    """Return ``True`` if *variant*'s devices form a connected path between
    its ``in`` and ``out`` ports (all current compensation variants do).
    """
    adjacency: dict[str, set[str]] = {}
    for dev in variant.devices:
        nets = set(dev.terminals.values())
        for net in nets:
            adjacency.setdefault(net, set()).update(nets)
    frontier, seen = ["in"], {"in"}
    while frontier:
        net = frontier.pop()
        if net == "out":
            return True
        for nxt in adjacency.get(net, ()):
            if nxt not in seen:
                seen.add(nxt)
                frontier.append(nxt)
    return False


[docs] def stage_inversions(variant: ModuleVariant) -> int | None: """Return the number of common-source inversions along *variant*'s ``in -> out`` device chain, or ``None`` if the chain cannot be followed (no device gates ``in``, a dangling hop, or a cycle). Each hop enters a MOSFET gate; leaving through the drain counts one inversion (common source), leaving through the source counts none (follower). A diode-connected device (``d == g == net``) is skipped at a fork -- it terminates a branch (e.g. a current-mirror master) rather than carrying the signal forward -- so the walk follows the mirror-output device instead. ``common_source_nmos``/``common_source_pmos`` -> 1, ``common_drain_pmos``/``common_drain_nmos`` -> 0, ``differential_ota_second_stage`` -> 2, ``noninverting_stage_nmos``/``noninverting_stage_pmos`` -> 2 (CS inversion + current-mirror inversion). """ net = "in" inversions = 0 seen: set[str] = set() while True: if net in seen: return None seen.add(net) dev = next( ( d for d in variant.devices if d.type in _MOS_TYPES and d.terminals.get("g") == net and d.terminals.get("d") != net ), None, ) if dev is None: return None if dev.terminals.get("s") == "out": return inversions drain = dev.terminals.get("d") if drain is None: return None inversions += 1 if drain == "out": return inversions net = drain
[docs] def is_compensation_compatible( topology: TopologyTemplate, variant_map: dict[str, ModuleVariant], ) -> bool: """Return ``False`` if any ``compensation`` slot that couples its ``in`` and ``out`` nets wraps a stage chain whose total inversion count is a positive even number -- non-inverting with gain, i.e. positive feedback through the compensation network (see the module docstring for the electrical rationale). """ stage_by_in_net: dict[str, tuple[str, str]] = {} for slot in topology.slots: if slot.category != "amplification_stage": continue conns = topology.slot_connections(slot.name) if "in" in conns and "out" in conns: stage_by_in_net[conns["in"]] = (slot.name, conns["out"]) for slot in topology.slots: if slot.category != "compensation": continue variant = variant_map.get(slot.name) if variant is None or not _couples_in_out(variant): continue conns = topology.slot_connections(slot.name) in_net, out_net = conns.get("in"), conns.get("out") if in_net is None or out_net is None: continue inversions = 0 net = in_net visited: set[str] = set() classifiable = True while net != out_net: if net in visited or net not in stage_by_in_net: classifiable = False break visited.add(net) stage_name, stage_out = stage_by_in_net[net] stage_variant = variant_map.get(stage_name) stage_inv = ( stage_inversions(stage_variant) if stage_variant is not None else None ) if stage_inv is None: classifiable = False break inversions += stage_inv net = stage_out if classifiable and inversions > 0 and inversions % 2 == 0: return False return True