From a7ed334bd6df67cf8c17c426c9b1ff2f3790814d Mon Sep 17 00:00:00 2001 From: euler Date: Mon, 3 Aug 2026 15:51:38 -0500 Subject: [PATCH 1/4] fet: allow narrow devices via dogbone diffusion nmos/pmos raise ValueError below W=0.5um in gf180, and forcing the width through produces 12 CO.4 violations (COMP overlap of contact). The limit is in the generator, not the technology: the PDK's own pcell declares wmin=0.22 and lays out a 0.22um device DRC-clean, with the COMP at two heights. Root cause: the diffusion rectangle is sized with the channel width diff_dims = (diff_extra_enc + evaluate_bbox(multiplier)[0], width) so a narrow channel drags the contact diffusion down with it and CO.4 fails. Electrical width is set by the poly over the channel, not by the diffusion at the contacts -- they can differ. That is the standard dogbone layout. Three coordinated changes, since poly_height, the via arrays and the implants all derive from the same value: - __comp_min_width(pdk): mcon.width + 2*mcon-active_diff.min_enclosure, read from the PDK rules rather than hardcoded. Falls back to 0.0, which preserves previous behaviour if the rules are missing. - S/D via array and diffusion sized by max(width, comp_min) - poly_height follows the COMP, otherwise narrow devices trip PL.4_LV Verified on two technologies, no code changes between them -- the helper picks up each PDK's own rules: gf180 comp_min 0.36 (mcon 0.22 + 2x0.07), KLayout deck FEOL+BEOL W=0.22 ValueError -> 0 violations W=0.28 ValueError -> 0 violations W=0.36 ValueError -> 0 violations W=0.50 0 violations, geometry unchanged W=1.00 / 1.25 L=50 / 2.00 nf=2 geometry unchanged sky130 comp_min 0.30 (mcon 0.17 + 2x0.06), Magic DRC W=0.15 0 errors <- PDK absolute minimum W=0.29 0 errors W=0.42 0 errors W=1.00 0 errors No regression: at or above the previous minimum the output is identical polygon by polygon (compared geometry, not file hashes -- cell names change because no_exception joins the via_array signature). Note for users: a dogbone device is not electrically equivalent to a rectangular one of the same W. Source/drain resistance is no longer inversely proportional to channel width because part of the current flows through the widened diffusion (Solid-State Electronics 44(1), dogbone geometry in narrow-width MOSFETs). Re-characterize before relying on models fitted to rectangular geometry. --- src/glayout/primitives/fet.py | 45 +++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/glayout/primitives/fet.py b/src/glayout/primitives/fet.py index 9f4ac6d3..db628aed 100644 --- a/src/glayout/primitives/fet.py +++ b/src/glayout/primitives/fet.py @@ -14,6 +14,32 @@ from glayout.spice import Netlist +def __comp_min_width(pdk: MappedPDK) -> float: + """Minimum diffusion height that can host a contact (rule CO.4 in gf180). + + The COMP region must enclose the contact by min_enclosure on every side: + + mcon.width + 2 * mcon-active_diff.min_enclosure + + which is 0.22 + 2*0.07 = 0.36um in gf180. Below that the *channel* may + still be narrower, but the diffusion under the contact may not: that is + the classic "dogbone" geometry, and it is what the PDK's own pcell does + (gf180mcu::nfet_03v3_draw declares wmin=0.22 and lays out a 0.22um device + with the COMP at two different heights). + + Derived from the PDK rules rather than hardcoded, so it carries over to + other technologies. Returns 0.0 if the rules are unavailable, which keeps + the previous behaviour. + """ + try: + return pdk.snap_to_2xgrid( + pdk.get_grule("mcon")["width"] + + 2 * pdk.get_grule("mcon", "active_diff")["min_enclosure"] + ) + except Exception: + return 0.0 + + @validate_arguments def __gen_fingers_macro(pdk: MappedPDK, rmult: int, fingers: int, length: float, width: float, poly_height: float, sdlayer: str, inter_finger_topmet: str) -> Component: """internal use: returns an array of fingers""" @@ -30,8 +56,12 @@ def __gen_fingers_macro(pdk: MappedPDK, rmult: int, fingers: int, length: float, # create a single finger finger = Component("finger") gate = finger << rectangle(size=(length, poly_height), layer=pdk.get_glayer("poly"), centered=True) - sd_viaarr = via_array(pdk, "active_diff", "met1", size=(sd_viaxdim, width), minus1=True, lay_bottom=False).copy() - interfinger_correction = via_array(pdk,"met1",inter_finger_topmet, size=(None, width),lay_every_layer=True, num_vias=(1,None)) + # Contacts are sized by the *contact* width, not the channel width: a + # narrow channel does not prevent contacting, the diffusion simply widens + # under the contact (dogbone). Without this, small W violates CO.4. + contact_width = max(width, __comp_min_width(pdk)) + sd_viaarr = via_array(pdk, "active_diff", "met1", size=(sd_viaxdim, contact_width), minus1=True, lay_bottom=False, no_exception=True).copy() + interfinger_correction = via_array(pdk,"met1",inter_finger_topmet, size=(None, contact_width),lay_every_layer=True, num_vias=(1,None), no_exception=True) sd_viaarr << interfinger_correction sd_viaarr_ref = finger << sd_viaarr sd_viaarr_ref.movex((poly_spacing+length) / 2) @@ -50,7 +80,11 @@ def __gen_fingers_macro(pdk: MappedPDK, rmult: int, fingers: int, length: float, # create diffusion and +doped region multiplier = rename_ports_by_orientation(centered_farray) diff_extra_enc = 2 * pdk.get_grule("mcon", "active_diff")["min_enclosure"] - diff_dims =(diff_extra_enc + evaluate_bbox(multiplier)[0], width) + # The diffusion cannot be as short as the channel if that leaves the + # contact without its CO.4 margin. Electrical width is set by the poly + # over the channel, not by the diffusion at the contacts. + comp_w = max(width, __comp_min_width(pdk)) + diff_dims =(diff_extra_enc + evaluate_bbox(multiplier)[0], comp_w) diff = multiplier << rectangle(size=diff_dims,layer=pdk.get_glayer("active_diff"),centered=True) sd_diff_ovhg = pdk.get_grule(sdlayer, "active_diff")["min_enclosure"] sdlayer_dims = [dim + 2*sd_diff_ovhg for dim in diff_dims] @@ -213,7 +247,10 @@ def multiplier( min_width = max(min_length, pdk.get_grule("active_diff")["min_width"]) width = min_width if (width or min_width) <= min_width else width width = pdk.snap_to_2xgrid(width) - poly_height = width + 2 * pdk.get_grule("poly", "active_diff")["overhang"] + # Poly must overhang the COMP, and COMP may be wider than the channel + # (see __comp_min_width). Sizing this on 'width' alone leaves the poly + # short for narrow devices and trips PL.4_LV (poly2 end cap). + poly_height = max(width, __comp_min_width(pdk)) + 2 * pdk.get_grule("poly", "active_diff")["overhang"] # call finger array multiplier = __gen_fingers_macro(pdk, interfinger_rmult, fingers, length, width, poly_height, sdlayer, inter_finger_topmet) # route all drains/ gates/ sources From e5f8518b6bb54e4ccce84393a749b52f5a3bacd0 Mon Sep 17 00:00:00 2001 From: euler Date: Mon, 3 Aug 2026 16:16:39 -0500 Subject: [PATCH 2/4] tests: cover narrow fets in the DRC CI The DRC matrix only exercises composite cells at comfortable widths, so nothing catches a regression in the narrow-device path. Adds nmos/pmos at each PDK's minimum diffusion width: gf180 W=0.22 L=0.28 (active_diff min_width) sky130 W=0.15 L=0.15 Both fail on main with CO.4 (COMP overlap of contact) and pass with the dogbone fix. Verified locally with tests/drc/run_cell_drc.py: gf180 2 passed, 0 failed sky130 2 passed, 0 failed --- tests/drc/run_cell_drc.py | 4 ++++ tests/parameters/ci_drc_gf180.csv | 2 ++ tests/parameters/ci_drc_sky130.csv | 2 ++ 3 files changed, 8 insertions(+) diff --git a/tests/drc/run_cell_drc.py b/tests/drc/run_cell_drc.py index 65fb5c9b..2fb9369d 100644 --- a/tests/drc/run_cell_drc.py +++ b/tests/drc/run_cell_drc.py @@ -53,6 +53,10 @@ class CellSpec: "diff_pair_ibias": "glayout.cells.composite:diff_pair_ibias", "low_voltage_cmirror": "glayout.cells.composite:low_voltage_cmirror", "opamp": "glayout.cells.composite:opamp", + # Narrow primitives: the diffusion under a contact cannot shrink with the + # channel (CO.4 in gf180), so these exercise the dogbone path in fet.py. + "nmos_narrow": "glayout.primitives.fet:nmos", + "pmos_narrow": "glayout.primitives.fet:pmos", } diff --git a/tests/parameters/ci_drc_gf180.csv b/tests/parameters/ci_drc_gf180.csv index 165214f7..00f4b6ca 100644 --- a/tests/parameters/ci_drc_gf180.csv +++ b/tests/parameters/ci_drc_gf180.csv @@ -8,3 +8,5 @@ differential_to_single_ended_converter,"{""rmult"": 3, ""half_pload"": [3.0, 1.0 diff_pair_ibias,"{""half_diffpair_params"": [5.0, 1.0, 1], ""diffpair_bias"": [5.0, 2.0, 1], ""rmult"": 2, ""with_antenna_diode_on_diffinputs"": 0}" low_voltage_cmirror,"{""width"": [4.0, 1.5], ""length"": 2.0, ""fingers"": [2, 1], ""multipliers"": [1, 1]}" opamp,"{""half_diffpair_params"": [5.0, 1.0, 1], ""diffpair_bias"": [5.0, 2.0, 1], ""half_common_source_params"": [7.0, 1.0, 10, 5], ""half_common_source_bias"": [6.0, 2.0, 8, 4], ""half_pload"": [6.0, 1.0, 5], ""add_output_stage"": false, ""with_antenna_diode_on_diffinputs"": 0, ""rmult"": 1}" +nmos_narrow,"{""width"": 0.22, ""length"": 0.28}" +pmos_narrow,"{""width"": 0.22, ""length"": 0.28}" diff --git a/tests/parameters/ci_drc_sky130.csv b/tests/parameters/ci_drc_sky130.csv index b4a4f46d..5c61030c 100644 --- a/tests/parameters/ci_drc_sky130.csv +++ b/tests/parameters/ci_drc_sky130.csv @@ -8,3 +8,5 @@ differential_to_single_ended_converter,"{""rmult"": 1, ""half_pload"": [3.0, 1.0 diff_pair_ibias,"{""half_diffpair_params"": [5.0, 1.0, 1], ""diffpair_bias"": [5.0, 2.0, 1], ""rmult"": 1, ""with_antenna_diode_on_diffinputs"": 0}" low_voltage_cmirror,"{""width"": [4.0, 1.5], ""length"": 2.0, ""fingers"": [2, 1], ""multipliers"": [1, 1]}" opamp,"{""half_diffpair_params"": [6, 1, 4], ""diffpair_bias"": [6, 2, 4], ""half_common_source_params"": [7, 1, 10, 3], ""half_common_source_bias"": [6, 2, 8, 2], ""output_stage_params"": [5, 1, 16], ""output_stage_bias"": [6, 2, 4], ""half_pload"": [6, 1, 6], ""mim_cap_size"": [12, 12], ""mim_cap_rows"": 3, ""rmult"": 2, ""with_antenna_diode_on_diffinputs"": 0, ""add_output_stage"": false}" +nmos_narrow,"{""width"": 0.15, ""length"": 0.15}" +pmos_narrow,"{""width"": 0.15, ""length"": 0.15}" From 5ccdf32442fc2659d4e85693702a7884c2f3bd0b Mon Sep 17 00:00:00 2001 From: euler Date: Mon, 10 Aug 2026 10:03:06 -0500 Subject: [PATCH 3/4] fet: que el dogbone deje el canal estrecho de verdad El ancho pedido no llegaba al layout. Dos recortes se acumulaban: el suelo de width usaba max(min_length, active_diff min_width), y min_length es el ancho minimo del poly, que limita la LONGITUD del canal, no su anchura; eso subia 0.22 a 0.28. Despues la difusion se dibujaba entera a comp_w, subiendo 0.28 a 0.36. Asi que un nfet pedido a 0.22 salia de 0.36 sin ningun aviso: genera, pasa DRC, y es otro dispositivo. Con f = 24837*W^-1.076 eso son 41% de frecuencia de menos en una celda LIF. Ahora la difusion es el dogbone que describia el comentario: una tira de altura width a lo largo del dispositivo y un pad de comp_w en cada contacto. El poly vuelve a dimensionarse sobre el canal. Y si aun asi hay que recortar el ancho, se avisa. gf180 nfet y pfet de 0.22 a 3.5 y sky130 desde 0.15: canal de la anchura pedida presente y DRC 0. Para width >= comp_min no se dibuja ningun pad, asi que la geometria de los dispositivos normales no cambia. --- src/glayout/primitives/fet.py | 42 ++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/src/glayout/primitives/fet.py b/src/glayout/primitives/fet.py index db628aed..98a30bd6 100644 --- a/src/glayout/primitives/fet.py +++ b/src/glayout/primitives/fet.py @@ -12,6 +12,7 @@ from decimal import Decimal from glayout.routing.straight_route import straight_route from glayout.spice import Netlist +from warnings import warn def __comp_min_width(pdk: MappedPDK) -> float: @@ -80,12 +81,26 @@ def __gen_fingers_macro(pdk: MappedPDK, rmult: int, fingers: int, length: float, # create diffusion and +doped region multiplier = rename_ports_by_orientation(centered_farray) diff_extra_enc = 2 * pdk.get_grule("mcon", "active_diff")["min_enclosure"] - # The diffusion cannot be as short as the channel if that leaves the - # contact without its CO.4 margin. Electrical width is set by the poly - # over the channel, not by the diffusion at the contacts. + # The channel is `width` tall; the diffusion only has to widen where a + # contact sits on it, so that CO.4's enclosure is met. Widening the whole + # strip instead would be simpler to draw and would silently build a wider + # device than the caller asked for -- a 0.22um request coming out as + # 0.36um, with no error to notice it by. Hence the dogbone: a narrow strip + # the full length, with a pad at each source/drain contact. comp_w = max(width, __comp_min_width(pdk)) - diff_dims =(diff_extra_enc + evaluate_bbox(multiplier)[0], comp_w) - diff = multiplier << rectangle(size=diff_dims,layer=pdk.get_glayer("active_diff"),centered=True) + diff_len = diff_extra_enc + evaluate_bbox(multiplier)[0] + diff_dims = (diff_len, comp_w) + diff = multiplier << rectangle(size=(diff_len, width), + layer=pdk.get_glayer("active_diff"), + centered=True) + if comp_w > width: + pad_dims = (sd_viaxdim + diff_extra_enc, comp_w) + pitch = poly_spacing + length + for contact in range(fingers + 1): + pad = multiplier << rectangle(size=pad_dims, + layer=pdk.get_glayer("active_diff"), + centered=True) + pad.movex((contact - fingers / 2) * pitch) sd_diff_ovhg = pdk.get_grule(sdlayer, "active_diff")["min_enclosure"] sdlayer_dims = [dim + 2*sd_diff_ovhg for dim in diff_dims] sdlayer_ref = multiplier << rectangle(size=sdlayer_dims, layer=pdk.get_glayer(sdlayer),centered=True) @@ -244,13 +259,20 @@ def multiplier( min_length = pdk.get_grule("poly")["min_width"] length = min_length if (length or min_length) <= min_length else length length = pdk.snap_to_2xgrid(length) - min_width = max(min_length, pdk.get_grule("active_diff")["min_width"]) + # The floor on width is the diffusion's own min width -- 0.22um in gf180, + # matching the wmin the foundry pcell declares. It used to be max(that, + # min_length), but min_length is the poly's min width, which constrains + # the channel *length*: using it here confused two different dimensions + # and quietly turned a requested 0.22um device into a 0.28um one. + min_width = pdk.get_grule("active_diff")["min_width"] + if width and width < min_width: + warn(f"width {width}um is below the {min_width}um minimum for " + f"{pdk.name}; building a {min_width}um device instead") width = min_width if (width or min_width) <= min_width else width width = pdk.snap_to_2xgrid(width) - # Poly must overhang the COMP, and COMP may be wider than the channel - # (see __comp_min_width). Sizing this on 'width' alone leaves the poly - # short for narrow devices and trips PL.4_LV (poly2 end cap). - poly_height = max(width, __comp_min_width(pdk)) + 2 * pdk.get_grule("poly", "active_diff")["overhang"] + # Poly overhangs the channel, which is `width` tall -- the diffusion is + # wider only at the contacts, and the poly does not run over those. + poly_height = width + 2 * pdk.get_grule("poly", "active_diff")["overhang"] # call finger array multiplier = __gen_fingers_macro(pdk, interfinger_rmult, fingers, length, width, poly_height, sdlayer, inter_finger_topmet) # route all drains/ gates/ sources From 97e2b539f3fd237aa742301091fa96d9114037b7 Mon Sep 17 00:00:00 2001 From: euler Date: Mon, 10 Aug 2026 10:49:56 -0500 Subject: [PATCH 4/4] fet: el dogbone tambien tiene que cumplir end cap y espaciado Estrechar el canal deja los pads de contacto sobresaliendo 0.07um, y eso abre dos frentes que el dispositivo ancho no tenia: - PL.4: el poly capea 0.22um sobre el canal pero se queda a 0.17um de las esquinas del pad. El end cap se mide contra la difusion mas ancha que el poly tiene al lado, asi que vuelve a dimensionarse sobre comp_w. No reensancha el dispositivo: la anchura electrica es poly sobre COMP, y COMP sigue midiendo width bajo la puerta. - DF.3a: el hueco entre dos pads es una muesca en la difusion y debe respetar el espaciado de COMP. Salia a 0.262um contra una regla de 0.28. Se resuelve subiendo el paso entre puertas, no estrechando el pad: el dispositivo crece en x y conserva la anchura pedida. Volver a la difusion uniforme habria cambiado W en silencio otra vez. nfet y pfet a W=0.22 con L=0.28 y L=0.30: DRC 0. W=0.50 y W=3.50 siguen dando 5.600 y 8.600 de alto, la misma geometria que antes del dogbone. --- src/glayout/primitives/fet.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/glayout/primitives/fet.py b/src/glayout/primitives/fet.py index 98a30bd6..c83a1607 100644 --- a/src/glayout/primitives/fet.py +++ b/src/glayout/primitives/fet.py @@ -54,6 +54,15 @@ def __gen_fingers_macro(pdk: MappedPDK, rmult: int, fingers: int, length: float, poly_spacing = max(sd_viaxdim, poly_spacing) met1_minsep = pdk.get_grule("met1")["min_separation"] poly_spacing += met1_minsep if length < met1_minsep else 0 + # On a dogbone the pads stand proud of the channel, so the gap between two + # of them is a notch in the diffusion and owes COMP-to-COMP spacing. Left + # alone it comes out at 0.26um against a 0.28um rule. Widening the gate + # pitch fixes it without touching the channel, so the device stays the + # width that was asked for and only grows along x. + if max(width, __comp_min_width(pdk)) > width: + pad_xdim = sd_viaxdim + 2 * pdk.get_grule("mcon", "active_diff")["min_enclosure"] + comp_space = pdk.get_grule("active_diff")["min_separation"] + poly_spacing = max(poly_spacing, pad_xdim + comp_space - length) # create a single finger finger = Component("finger") gate = finger << rectangle(size=(length, poly_height), layer=pdk.get_glayer("poly"), centered=True) @@ -270,9 +279,12 @@ def multiplier( f"{pdk.name}; building a {min_width}um device instead") width = min_width if (width or min_width) <= min_width else width width = pdk.snap_to_2xgrid(width) - # Poly overhangs the channel, which is `width` tall -- the diffusion is - # wider only at the contacts, and the poly does not run over those. - poly_height = width + 2 * pdk.get_grule("poly", "active_diff")["overhang"] + # The end cap has to clear the *widest* diffusion the poly runs beside, + # which on a dogbone is the contact pad, not the channel. Sizing this on + # `width` alone leaves the poly 0.17um short at the pad corners and trips + # PL.4. Note this does not widen the device: electrical width is poly + # over COMP, and COMP is still `width` tall under the gate. + poly_height = max(width, __comp_min_width(pdk)) + 2 * pdk.get_grule("poly", "active_diff")["overhang"] # call finger array multiplier = __gen_fingers_macro(pdk, interfinger_rmult, fingers, length, width, poly_height, sdlayer, inter_finger_topmet) # route all drains/ gates/ sources