"""
Demand-driven bias-generation construction for
:func:`~circuitgenome.synthesizer.synthesizer.enumerate_circuits`.
The bias generator is not an enumerated module variant: it is *constructed*
per combination from what the other slots actually consume on each bias rail.
This replaces the enumerate-then-filter-then-prune chain that the fixed
``bias_generation`` variants needed (issues #99/#101/#102): every consumed
rail gets exactly the leg its consumer requires, so flavor mismatches,
unused legs, and the redundant rail-7 diode cannot arise in the first place.
Each consumed rail is classified into a **kind** -- the type system this
module is built around:
- ``gate_vdd`` -- a consumer MOSFET gate whose source sits at vdd (a PMOS
current source). The rail must sit one ``|V_GSP|`` below vdd, and the leg's
diode-connected PMOS is the mirror *master* of its consumers: the sizer
sets the consumer current by W/L ratio instead of hoping a voltage matches.
- ``gate_gnd`` -- mirror image: consumer gate with source at gnd (NMOS
current sink); NMOS diode leg one ``V_GSN`` above gnd.
- ``current_source`` -- the consumer brings its own NMOS reference diode (a
current-mirror tail's mirror diode). The rail
is a *current* interface: the leg is a bare PMOS mirror sourcing the
reference current into the consumer's diode. A voltage-style leg here
would either duplicate the consumer's diode (parallel diodes splitting the
reference current) or fight it (the measured 22x rail-7 contention of
issue #99).
- ``current_sink`` -- mirror image for a PMOS-diode consumer: bare NMOS
mirror sinking the reference current.
- ``cascode_gnd`` -- an NMOS cascode gate: consumer gate on the rail with
its source on an *internal* node (a stacked device below). The rail must
sit at the cascode's ``V_GS`` plus the saturation floor of the stack under
it, so the leg is a diode-connected NMOS (the tracking ``V_GS`` part) on a
small floor resistor (``out = V_GSN + I*R``), both sized per-rail by the
sizer from the consumer stack (issue #99's parked cascode class).
- ``cascode_vdd`` -- mirror image for PMOS cascode gates: diode-connected
PMOS on a resistor to vdd (``out = vdd - |V_GSP| - I*R``).
- ``tunable`` -- no structurally implied level: conflicting demands on a
shared rail. The leg is a PMOS mirror into a resistor
(``out = I_leg * R``), per-rail tunable by the sizer (issue #100).
The leg templates and the multi-reference core (NMOS master on the ``ibias``
pin, plus a ``pref`` branch deriving the PMOS-side mirror reference, emitted
only when a leg needs it) live in ``config/bias_legs.yaml`` -- see its header
for the electrical layout and the template net-name contract.
Both the demand analysis and the assembly are **structural** (actual device
terminal references, no YAML tags), so new consumer variants are classified
correctly without code changes. Construction must run **after**
:func:`~circuitgenome.synthesizer.compatibility.cmfb.prune_cmfb` /
:func:`~circuitgenome.synthesizer.compatibility.tail_current.prune_tail_current`,
so emptied placeholder slots demand nothing.
"""
from __future__ import annotations
from .models import BiasLegLibrary, Device, ModuleVariant, PortDef, TopologyTemplate
_BIAS_NET_INDEX = {f"net_bias{i}": i for i in range(1, 10)}
_SUPPLIES = ("vdd", "gnd")
#: Rail kinds whose legs mirror from the PMOS-side reference gate. Present
#: for documentation; the pref branch is actually emitted on a structural
#: check (an instantiated leg terminal referencing ``pref``).
PREF_KINDS = frozenset({"gate_gnd", "current_source", "tunable", "cascode_gnd"})
#: Template-local nets instantiated once per rail (rewritten ``{net}{i}``);
#: every other template net (ibias/pref/vdd/gnd) is shared across legs.
_PER_LEG_NETS = ("out", "mid")
[docs]
def rail_flavor_from_diode(devices: list[Device], node: str) -> str | None:
"""Return the supply a diode-connected MOSFET on *node* pins it to.
A diode-connected device (``d == g == node``) ties the node one ``V_GS``
away from whatever its source ultimately rests on, and its channel type
names that supply: PMOS conducts from vdd (``"vdd"``), NMOS to gnd
(``"gnd"``). The channel type is used rather than the source terminal so
stacked references (a cascode tail's ``d == g == bias`` device whose
source is an internal node of the stack) resolve correctly. Returns
``None`` when no diode-connected MOSFET sits on *node*.
"""
for dev in devices:
if (
dev.type in ("nmos", "pmos")
and dev.terminals.get("d") == node
and dev.terminals.get("g") == node
):
return "vdd" if dev.type == "pmos" else "gnd"
return None
def _device_votes(
variant: ModuleVariant, port_name: str
) -> tuple[set[str], set[str], set[str]]:
"""Return ``(diode_kinds, gate_kinds, cascode_kinds)`` demanded on *port_name*.
Per consumer MOSFET whose gate lands on the port:
- diode-connected on the port (``d == g == port``): the consumer is a
mirror reference converting a *current* into its own gate voltage --
an NMOS diode wants current sourced in (``current_source``), a PMOS
diode wants it sunk out (``current_sink``);
- else, source on a supply: the gate needs a voltage one ``V_GS`` from
that supply (``gate_vdd`` / ``gate_gnd``);
- else (source on an internal node -- a cascode gate): the gate needs
its ``V_GS`` plus the stack's saturation floor, referenced to the
supply its channel type conducts toward (``cascode_gnd`` for NMOS,
``cascode_vdd`` for PMOS).
"""
diode_kinds: set[str] = set()
gate_kinds: set[str] = set()
cascode_kinds: set[str] = set()
for dev in variant.devices:
if dev.type not in ("nmos", "pmos"):
continue
if dev.terminals.get("g") != port_name:
continue
if dev.terminals.get("d") == port_name:
diode_kinds.add(
"current_source" if dev.type == "nmos" else "current_sink"
)
elif dev.terminals.get("s") in _SUPPLIES:
gate_kinds.add(f"gate_{dev.terminals['s']}")
else:
cascode_kinds.add(
"cascode_gnd" if dev.type == "nmos" else "cascode_vdd"
)
return diode_kinds, gate_kinds, cascode_kinds
[docs]
def required_rail_kinds(
topology: TopologyTemplate,
variant_map: dict[str, ModuleVariant],
) -> dict[int, str]:
"""Return ``{rail_index: kind}`` for every bias rail actually consumed.
A rail is consumed when any device terminal of the slot's variant
references the port the topology wires to ``net_bias1``..``net_bias8``
(declared-but-unwired ``optional`` ports are ignored, exactly like the
retired ``needed_bias_outputs``). The kind combines the per-device votes
of :func:`_device_votes` across all consuming slots:
- a diode vote wins over gate votes: the consumer's own diode makes the
rail a current interface, and any mirror gates riding on it (the tail's
output device) are slaved to that diode, not to the leg;
- a single gate kind is taken as-is;
- a single cascode kind (and no diode/gate votes) is taken as-is -- a
supply-referenced gate sharing the rail would need one ``V_GS`` where
the cascode needs ``V_GS`` plus a floor, so that mix is a conflict;
- conflicting votes fall back to ``tunable`` -- the honest "no single
structural level implied" answer.
Unconsumed rails are absent from the result and get no leg at all.
"""
diode_votes: dict[int, set[str]] = {}
gate_votes: dict[int, set[str]] = {}
cascode_votes: dict[int, set[str]] = {}
consumed: set[int] = set()
for slot in topology.slots:
if slot.category == "bias_generation":
continue
variant = variant_map[slot.name]
used_local_nets = {
local_net
for dev in variant.devices
for local_net in dev.terminals.values()
}
for port_name, net in topology.slot_connections(slot.name).items():
idx = _BIAS_NET_INDEX.get(net)
if idx is None or port_name not in used_local_nets:
continue
consumed.add(idx)
diode_kinds, gate_kinds, cascode_kinds = _device_votes(variant, port_name)
diode_votes.setdefault(idx, set()).update(diode_kinds)
gate_votes.setdefault(idx, set()).update(gate_kinds)
cascode_votes.setdefault(idx, set()).update(cascode_kinds)
kinds: dict[int, str] = {}
for idx in consumed:
diodes = diode_votes.get(idx, set())
gates = gate_votes.get(idx, set())
cascodes = cascode_votes.get(idx, set())
if len(diodes) == 1:
kinds[idx] = next(iter(diodes))
elif not diodes and len(gates) == 1 and not cascodes:
kinds[idx] = next(iter(gates))
elif not diodes and not gates and len(cascodes) == 1:
kinds[idx] = next(iter(cascodes))
else:
kinds[idx] = "tunable"
return kinds
[docs]
def construct_bias_generation(
topology: TopologyTemplate,
variant_map: dict[str, ModuleVariant],
library: BiasLegLibrary,
) -> ModuleVariant:
"""Assemble the bias-generation variant this combination needs.
Emits the master reference, one leg per consumed rail (template chosen by
:func:`required_rail_kinds`, with the per-leg nets ``out``/``mid``
rewritten to ``out{i}``/``mid{i}`` and each ref suffixed with the rail
index), and the ``pref`` branch when any instantiated leg references the
``pref`` net. The result is a normal
:class:`~circuitgenome.synthesizer.models.ModuleVariant` (name
``constructed_bias``) -- downstream wiring, serialization, and the
recognizer treat it like any other variant. ``pref`` (and the pref
branch's ``prefsrc``/``ncasc``, and the cascode legs' ``mid{i}``) are not
ports, so they resolve to slot-internal nets.
"""
kinds = required_rail_kinds(topology, variant_map)
leg_devices: list[Device] = []
uses_pref = False
for idx in sorted(kinds):
for tmpl in library.legs[kinds[idx]]:
terminals = {
term: (f"{net}{idx}" if net in _PER_LEG_NETS else net)
for term, net in tmpl.terminals.items()
}
if "pref" in terminals.values():
uses_pref = True
leg_devices.append(
Device(ref=f"{tmpl.ref}{idx}", type=tmpl.type, terminals=terminals)
)
devices = list(library.reference)
if uses_pref:
devices.extend(library.pref_branch)
devices.extend(leg_devices)
ports = [PortDef(name="ibias", role="input")]
ports.extend(PortDef(name=f"out{idx}", role="output") for idx in sorted(kinds))
ports.append(PortDef(name="vdd", role="supply"))
ports.append(PortDef(name="gnd", role="supply"))
return ModuleVariant(
name="constructed_bias",
category="bias_generation",
display_name="Constructed Multi-Reference Bias",
ports=ports,
devices=devices,
)