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 dcf3aca7372b8ba6634b295e74652a7c68f9b8fc Mon Sep 17 00:00:00 2001 From: euler Date: Sun, 16 Aug 2026 21:28:40 -0500 Subject: [PATCH 3/4] fet: make the dogbone deliver the narrow channel it promises The requested width never reached the layout. Two roundings stacked: the width floor used max(min_length, active_diff min_width), and min_length is the poly's minimum width, which bounds the channel LENGTH rather than its width; that lifted 0.22 to 0.28. The diffusion was then drawn at comp_w throughout, lifting 0.28 to 0.36. So an nfet asked for 0.22 came out at 0.36 with no warning: it generates, it passes DRC, and it is a different device. With f = 24837*W^-1.076 that is 41% less frequency in a LIF cell. The diffusion is now the dogbone the comment already described: a strip of height width along the device and a comp_w pad at each contact. The poly is sized on the channel again, and if the width still has to be clipped it warns. gf180 nfet and pfet from 0.22 to 3.5 and sky130 from 0.15: channel at the requested width, DRC clean. For width >= comp_min no pad is drawn, so the geometry of normal devices does not change. --- 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 f75fc914abc42c36560367a1b4ad083ca4ec0d0f Mon Sep 17 00:00:00 2001 From: euler Date: Sun, 16 Aug 2026 21:28:41 -0500 Subject: [PATCH 4/4] fet: keep the dogbone within end cap and spacing rules Narrowing the channel leaves the contact pads protruding 0.07um, which opens two fronts the wide device did not have: - PL.4: the poly caps 0.22um over the channel but sits 0.17um from the pad corners. End cap is measured against the widest diffusion beside the poly, so it is sized on comp_w again. This does not re-widen the device: the electrical width is poly over COMP, and COMP still measures width under the gate. - DF.3a: the gap between two pads is a notch in the diffusion and owes COMP spacing. It came out at 0.262um against a 0.28 rule. Fixed by widening the gate pitch rather than narrowing the pad, so the device grows in x and keeps the requested width. Going back to uniform diffusion would have changed W silently again. nfet and pfet at W=0.22 with L=0.28 and L=0.30: DRC clean. W=0.50 and W=3.50 still come out 5.600 and 8.600 tall, the same geometry as before the 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