mirror of
https://github.com/TinyTapeout/tt-support-tools.git
synced 2026-11-03 04:36:09 +01:00
501 lines
18 KiB
Python
501 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import logging
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import traceback
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import gdstk
|
|
import klayout.db as pya
|
|
import klayout.rdb as rdb
|
|
import yaml
|
|
from klayout_tools import parse_lyp_layers
|
|
from pin_check import pin_check
|
|
from precheck_failure import PrecheckFailure
|
|
from tech_data import (
|
|
analog_pin_rects,
|
|
boundary_layer,
|
|
forbidden_layers,
|
|
lyp_filename,
|
|
tech_names,
|
|
valid_layers,
|
|
)
|
|
|
|
PDK_ROOT = os.getenv("PDK_ROOT")
|
|
PDK_NAME = os.getenv("PDK") or "sky130A"
|
|
LYP_DIR = f"{PDK_ROOT}/{PDK_NAME}/libs.tech/klayout/tech"
|
|
REPORTS_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "reports")
|
|
|
|
if not PDK_ROOT:
|
|
logging.error("PDK_ROOT environment variable not set")
|
|
exit(1)
|
|
|
|
|
|
def has_sky130_devices(gds: str):
|
|
for cell_name in gdstk.read_rawcells(gds):
|
|
if cell_name.startswith("sky130_fd_"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def load_layers(tech: str, only_valid: bool = True):
|
|
lyp_file = f"{LYP_DIR}/{lyp_filename[tech]}"
|
|
layers = parse_lyp_layers(lyp_file, only_valid)
|
|
return layers
|
|
|
|
|
|
def magic_drc(gds: str, toplevel: str):
|
|
logging.info(f"Running magic DRC on {gds} (module={toplevel})")
|
|
|
|
magic = subprocess.run(
|
|
[
|
|
"magic",
|
|
"-noconsole",
|
|
"-dnull",
|
|
"-rcfile",
|
|
f"{PDK_ROOT}/{PDK_NAME}/libs.tech/magic/{PDK_NAME}.magicrc",
|
|
"magic_drc.tcl",
|
|
gds,
|
|
toplevel,
|
|
PDK_ROOT,
|
|
f"{REPORTS_PATH}/magic_drc.txt",
|
|
f"{REPORTS_PATH}/magic_drc.mag",
|
|
],
|
|
)
|
|
|
|
if magic.returncode != 0:
|
|
if not has_sky130_devices(gds):
|
|
logging.warning("No sky130 devices present - was the design flattened?")
|
|
raise PrecheckFailure("Magic DRC failed")
|
|
|
|
|
|
def klayout_custom_drc(
|
|
check: str, script_path: str, script_vars: dict[str, str], report_vars: list[str]
|
|
):
|
|
report_file = f"{REPORTS_PATH}/drc_{check}.xml"
|
|
klayout_args = ["klayout", "-b", "-r", script_path]
|
|
for k, v in script_vars.items():
|
|
klayout_args.extend(["-rd", f"{k}={v}"])
|
|
for k in report_vars:
|
|
klayout_args.extend(["-rd", f"{k}={report_file}"])
|
|
klayout = subprocess.run(klayout_args)
|
|
if klayout.returncode != 0:
|
|
raise PrecheckFailure(f"Klayout {check} failed")
|
|
|
|
report = rdb.ReportDatabase("DRC")
|
|
report.load(report_file)
|
|
|
|
if report.num_items() > 0:
|
|
raise PrecheckFailure(
|
|
f"Klayout {check} failed with {report.num_items()} DRC violations"
|
|
)
|
|
|
|
|
|
def klayout_drc(gds: str, check: str, script=f"{PDK_NAME}_mr.drc", extra_vars=[]):
|
|
logging.info(f"Running klayout {check} on {gds}")
|
|
if "/" not in script:
|
|
script = f"tech-files/{script}"
|
|
script_vars = {
|
|
check: "true",
|
|
"input": gds,
|
|
"thr": "1", # single-threaded operation in sky130A_mr.drc to work around klayout bug
|
|
}
|
|
script_vars.update(extra_vars)
|
|
klayout_custom_drc(check, script, script_vars, ["report", "report_file"])
|
|
|
|
|
|
def klayout_zero_area(gds: str):
|
|
return klayout_drc(gds, "zero_area", "zeroarea.rb.drc")
|
|
|
|
|
|
def klayout_sg13cmos5l(gds: str):
|
|
return klayout_drc(
|
|
gds,
|
|
"sg13cmos5l",
|
|
f"{PDK_ROOT}/{PDK_NAME}/libs.tech/klayout/tech/drc/ihp-sg13cmos5l.drc",
|
|
)
|
|
|
|
|
|
def klayout_checks(gds: str, expected_name: str, tech: str):
|
|
layout = pya.Layout()
|
|
layout.read(gds)
|
|
layers = load_layers(tech)
|
|
|
|
logging.info("Running top macro name check...")
|
|
top_cell = layout.top_cell()
|
|
if top_cell.name != expected_name:
|
|
raise PrecheckFailure(
|
|
f"Top macro name mismatch: expected {expected_name}, got {top_cell.name}"
|
|
)
|
|
|
|
logging.info("Running forbidden layer check...")
|
|
for layer in forbidden_layers[tech]:
|
|
layer_info = layers[layer]
|
|
logging.info(f"* Checking {layer_info.name}")
|
|
layer_index = layout.find_layer(layer_info.layer, layer_info.data_type)
|
|
if layer_index is not None:
|
|
raise PrecheckFailure(f"Forbidden layer {layer} found in {gds}")
|
|
|
|
logging.info("Running prBoundary check...")
|
|
layer_name = boundary_layer[tech]
|
|
layer_info = layers[layer_name]
|
|
layer_index = layout.find_layer(layer_info.layer, layer_info.data_type)
|
|
if layer_index is None:
|
|
calma_index = f"{layer_info.layer}/{layer_info.data_type}"
|
|
raise PrecheckFailure(f"{layer_name} ({calma_index}) layer not found in {gds}")
|
|
|
|
|
|
def boundary_check(gds: str, tech: str):
|
|
"""Ensure that there are no shapes outside the project area."""
|
|
lib = gdstk.read_gds(gds)
|
|
tops = lib.top_level()
|
|
if len(tops) != 1:
|
|
raise PrecheckFailure("GDS top level not unique")
|
|
top = tops[0]
|
|
boundary = top.copy("test_boundary")
|
|
|
|
layers = load_layers(tech)
|
|
layer_name = boundary_layer[tech]
|
|
layer_info = layers[layer_name]
|
|
boundary.filter([(layer_info.layer, layer_info.data_type)], False)
|
|
if top.bounding_box() != boundary.bounding_box():
|
|
raise PrecheckFailure("Shapes outside project area")
|
|
|
|
|
|
def power_pin_check(verilog: str, lef: str, uses_3v3: bool):
|
|
"""Ensure that VPWR / VGND are present and have USE definitions,
|
|
and that VAPWR is present if and only if 'uses_3v3' is set."""
|
|
verilog_s = open(verilog).read().replace("VPWR", "VDPWR")
|
|
lef_s = open(lef).read().replace("VPWR", "VDPWR")
|
|
|
|
# naive but good enough way to ignore comments
|
|
verilog_s = re.sub("//.*", "", verilog_s)
|
|
verilog_s = re.sub("/\\*.*?\\*/", "", verilog_s, flags=(re.DOTALL | re.MULTILINE))
|
|
|
|
# this looks for a line beginning with "PIN", captures the name of the pin and the body up until its "END"
|
|
PIN_PATTERN = re.compile(
|
|
r"^\s*PIN (VPWR|VDPWR|VAPWR|VGND)\s*([\s\S]+?(?=^\s*END \1))",
|
|
flags=re.MULTILINE,
|
|
)
|
|
|
|
for ft, s in (("Verilog", verilog_s), ("LEF", lef_s)):
|
|
for pwr, ex in (("VGND", True), ("VDPWR", True), ("VAPWR", uses_3v3)):
|
|
if (pwr in s) and not ex:
|
|
raise PrecheckFailure(f"{ft} contains {pwr}")
|
|
if not (pwr in s) and ex:
|
|
raise PrecheckFailure(f"{ft} doesn't contain {pwr}")
|
|
|
|
for match in PIN_PATTERN.finditer(lef_s):
|
|
pin, definition = match.groups()
|
|
|
|
match pin:
|
|
case "VPWR" | "VDPWR" | "VAPWR":
|
|
if "USE POWER" not in definition:
|
|
raise PrecheckFailure(
|
|
f"{pin} does not have a corresponding 'USE POWER ;'"
|
|
)
|
|
|
|
case "VGND":
|
|
if "USE GROUND" not in definition:
|
|
raise PrecheckFailure(
|
|
f"{pin} does not have a corresponding 'USE GROUND ;'"
|
|
)
|
|
|
|
case _:
|
|
raise PrecheckFailure(f"unhandled {pin}")
|
|
|
|
|
|
def layer_check(gds: str, tech: str):
|
|
"""Check that there are no invalid layers in the GDS file."""
|
|
layer_definition = load_layers(tech, only_valid=False)
|
|
lib = gdstk.read_gds(gds)
|
|
valid_layer_list = set(
|
|
map(
|
|
lambda layer_name: (
|
|
(
|
|
layer_definition[layer_name].layer,
|
|
layer_definition[layer_name].data_type,
|
|
)
|
|
if type(layer_name) is str
|
|
else layer_name
|
|
),
|
|
valid_layers[tech],
|
|
)
|
|
)
|
|
gds_layers = lib.layers_and_datatypes().union(lib.layers_and_texttypes())
|
|
excess = gds_layers - valid_layer_list
|
|
if excess:
|
|
raise PrecheckFailure(f"Invalid layers in GDS: {excess}")
|
|
|
|
|
|
def cell_name_check(gds: str):
|
|
"""Check that there are no cell names with '#' or '/' in them."""
|
|
for cell_name in gdstk.read_rawcells(gds):
|
|
if "#" in cell_name:
|
|
raise PrecheckFailure(
|
|
f"Cell name {cell_name} contains invalid character '#'"
|
|
)
|
|
if "/" in cell_name:
|
|
raise PrecheckFailure(
|
|
f"Cell_name {cell_name} contains invalid character '/'"
|
|
)
|
|
|
|
|
|
def urpm_nwell_check(gds: str, top_module: str):
|
|
"""Run a DRC check for urpm to nwell spacing."""
|
|
extra_vars = {"thr": os.cpu_count(), "top_cell": top_module}
|
|
klayout_drc(
|
|
gds=gds, check="nwell_urpm", script="nwell_urpm.drc", extra_vars=extra_vars
|
|
)
|
|
|
|
|
|
def analog_pin_check(
|
|
gds: str, tech: str, is_analog: bool, uses_3v3: bool, analog_pins: int, pinout: dict
|
|
):
|
|
"""Check that every analog pin connects to a piece of metal
|
|
if and only if the pin is used according to info.yaml."""
|
|
if is_analog:
|
|
lib = gdstk.read_gds(gds)
|
|
top = lib.top_level()[0]
|
|
filtered = {}
|
|
|
|
for pin, (rect, pin_layer, via_layers) in enumerate(
|
|
analog_pin_rects(tech, uses_3v3)
|
|
):
|
|
for layer in [pin_layer] + via_layers:
|
|
if layer not in filtered:
|
|
i = len(filtered)
|
|
lf = top.copy(f"test_lf_{i}")
|
|
lf.flatten()
|
|
lf.filter([layer], False)
|
|
filtered[layer] = lf
|
|
|
|
pin_rect = gdstk.rectangle(*rect)
|
|
pin_ring = gdstk.boolean(
|
|
gdstk.offset(pin_rect, 0.5), gdstk.offset(pin_rect, 0.1), "not"
|
|
)
|
|
|
|
pin_layer_polygons = filtered[pin_layer].polygons
|
|
connected = bool(gdstk.boolean(pin_layer_polygons, pin_ring, "and"))
|
|
for via_layer in via_layers:
|
|
via_layer_polygons = filtered[via_layer].polygons
|
|
connected = connected or bool(
|
|
gdstk.boolean(via_layer_polygons, pin_rect, "and")
|
|
)
|
|
|
|
expected_pc = pin < analog_pins
|
|
expected_pd = bool(pinout.get(f"ua[{pin}]", ""))
|
|
|
|
if connected and not expected_pc:
|
|
raise PrecheckFailure(
|
|
f"Analog pin `ua[{pin}]` is connected to some metal but `analog_pins` is set to {analog_pins} in `info.yaml`. Either increase `analog_pins` to at least {pin+1}, or remove any metal or via adjacent to `ua[{pin}]`."
|
|
)
|
|
elif connected and not expected_pd:
|
|
raise PrecheckFailure(
|
|
f"Analog pin `ua[{pin}]` is connected to some metal but the description of `ua[{pin}]` in the pinout section of `info.yaml` is empty. Either add a description or remove any metal or via adjacent to `ua[{pin}]`."
|
|
)
|
|
elif not connected and expected_pc:
|
|
raise PrecheckFailure(
|
|
f"Analog pin `ua[{pin}]` is not connected to any adjacent metal but `analog_pins` is set to {analog_pins} in `info.yaml`. Either wire up `ua[{pin}]` to your design or decrease `analog_pins` to {pin}."
|
|
)
|
|
elif not connected and expected_pd:
|
|
raise PrecheckFailure(
|
|
f"Analog pin `ua[{pin}]` is not connected to any adjacent metal but the description of `ua[{pin}]` in the pinout section of `info.yaml` is non-empty. Either wire up `ua[{pin}]` to your design or remove the description for the disconnected pin."
|
|
)
|
|
|
|
|
|
def main():
|
|
default_tech = PDK_NAME
|
|
if default_tech not in tech_names:
|
|
default_tech = tech_names[0]
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--gds", required=True)
|
|
parser.add_argument(
|
|
"--tech", required=False, default=default_tech, choices=tech_names
|
|
)
|
|
args = parser.parse_args()
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
logging.info(f"PDK_ROOT: {PDK_ROOT}")
|
|
logging.info(f"Tech: {args.tech}")
|
|
|
|
if args.gds.endswith(".gds"):
|
|
gds_stem = args.gds.removesuffix(".gds")
|
|
gds_temp = None
|
|
gds_file = args.gds
|
|
elif args.gds.endswith(".oas"):
|
|
gds_stem = args.gds.removesuffix(".oas")
|
|
gds_temp = tempfile.NamedTemporaryFile(suffix=".gds", delete=False)
|
|
gds_file = gds_temp.name
|
|
logging.info(f"Converting {args.gds} to {gds_file}")
|
|
layout = pya.Layout()
|
|
layout.read(args.gds)
|
|
layout.write(gds_file)
|
|
else:
|
|
raise PrecheckFailure("Layout file extension is neither .gds nor .oas")
|
|
|
|
tech = args.tech
|
|
if tech not in tech_names:
|
|
raise PrecheckFailure(f"Invalid tech: {tech}")
|
|
|
|
yaml_dir = os.path.dirname(args.gds)
|
|
while not os.path.exists(f"{yaml_dir}/info.yaml"):
|
|
yaml_dir = os.path.dirname(yaml_dir)
|
|
if yaml_dir in ("/", ""):
|
|
raise PrecheckFailure("info.yaml not found")
|
|
yaml_file = f"{yaml_dir}/info.yaml"
|
|
yaml_data = yaml.safe_load(open(yaml_file))
|
|
|
|
wokwi_id = yaml_data["project"].get("wokwi_id", 0)
|
|
top_module = yaml_data["project"].get("top_module", f"tt_um_wokwi_{wokwi_id}")
|
|
assert top_module == os.path.basename(gds_stem)
|
|
|
|
tiles = yaml_data.get("project", {}).get("tiles", "1x1")
|
|
analog_pins = yaml_data.get("project", {}).get("analog_pins", 0)
|
|
is_analog = analog_pins > 0
|
|
uses_3v3 = bool(yaml_data.get("project", {}).get("uses_3v3", False))
|
|
pinout = yaml_data.get("pinout", {})
|
|
if uses_3v3 and not is_analog:
|
|
raise PrecheckFailure("Projects with 3v3 power need at least one analog pin")
|
|
def_root = f"../tech/{tech}/def"
|
|
if is_analog:
|
|
if uses_3v3:
|
|
template_def = f"{def_root}/analog/tt_analog_{tiles}_3v3.def"
|
|
else:
|
|
template_def = f"{def_root}/analog/tt_analog_{tiles}.def"
|
|
elif tech == "ihp-sg13cmos5l" or tech == "gf180mcuD":
|
|
template_def = f"{def_root}/tt_block_{tiles}_pgvdd.def"
|
|
else:
|
|
template_def = f"{def_root}/tt_block_{tiles}_pg.def"
|
|
logging.info(f"using def template {template_def}")
|
|
|
|
gds_dir = os.path.dirname(gds_stem)
|
|
lef_file = gds_stem + ".lef"
|
|
lef_file_alt = os.path.join(
|
|
gds_dir, "..", "lef", os.path.basename(gds_stem) + ".lef"
|
|
)
|
|
if not os.path.exists(lef_file) and os.path.exists(lef_file_alt):
|
|
lef_file = lef_file_alt
|
|
verilog_file = gds_stem + ".v"
|
|
|
|
checks = [
|
|
{
|
|
"name": "Magic DRC",
|
|
"check": lambda: magic_drc(gds_file, top_module),
|
|
"techs": ["sky130A", "gf180mcuD"],
|
|
},
|
|
{
|
|
"name": "KLayout FEOL",
|
|
"check": lambda: klayout_drc(gds_file, "feol"),
|
|
"techs": ["sky130A"],
|
|
},
|
|
{
|
|
"name": "KLayout BEOL",
|
|
"check": lambda: klayout_drc(gds_file, "beol"),
|
|
"techs": ["sky130A"],
|
|
},
|
|
{
|
|
"name": "KLayout offgrid",
|
|
"check": lambda: klayout_drc(gds_file, "offgrid"),
|
|
"techs": ["sky130A"],
|
|
},
|
|
{
|
|
"name": "KLayout pin label overlapping drawing",
|
|
"check": lambda: klayout_drc(
|
|
gds_file,
|
|
"pin_label_purposes_overlapping_drawing",
|
|
"pin_label_purposes_overlapping_drawing.rb.drc",
|
|
),
|
|
},
|
|
{
|
|
"name": "KLayout SG13CMOS5L DRC",
|
|
"check": lambda: klayout_sg13cmos5l(gds_file),
|
|
"techs": ["ihp-sg13cmos5l"],
|
|
},
|
|
{"name": "KLayout zero area", "check": lambda: klayout_zero_area(gds_file)},
|
|
{
|
|
"name": "KLayout Checks",
|
|
"check": lambda: klayout_checks(gds_file, top_module, tech),
|
|
},
|
|
{
|
|
"name": "Pin check",
|
|
"check": lambda: pin_check(
|
|
gds_file, lef_file, template_def, top_module, uses_3v3, tech
|
|
),
|
|
},
|
|
{"name": "Boundary check", "check": lambda: boundary_check(gds_file, tech)},
|
|
{
|
|
"name": "Power pin check",
|
|
"check": lambda: power_pin_check(verilog_file, lef_file, uses_3v3),
|
|
"techs": ["sky130A", "gf180mcuD"],
|
|
},
|
|
{"name": "Layer check", "check": lambda: layer_check(gds_file, tech)},
|
|
{"name": "Cell name check", "check": lambda: cell_name_check(gds_file)},
|
|
{
|
|
"name": "urpm/nwell check",
|
|
"check": lambda: urpm_nwell_check(gds_file, top_module),
|
|
"techs": ["sky130A"],
|
|
},
|
|
{
|
|
"name": "Analog pin check",
|
|
"check": lambda: analog_pin_check(
|
|
gds_file, tech, is_analog, uses_3v3, analog_pins, pinout
|
|
),
|
|
"techs": ["sky130A", "ihp-sg13cmos5l"],
|
|
},
|
|
]
|
|
|
|
testsuite = ET.Element("testsuite", name="Tiny Tapeout Prechecks")
|
|
error_count = 0
|
|
markdown_table = "# Tiny Tapeout Precheck Results\n\n"
|
|
markdown_table += "| Check | Result |\n|-----------|--------|\n"
|
|
for check in checks:
|
|
name = check["name"]
|
|
if "techs" in check and tech not in check["techs"]:
|
|
continue
|
|
start_time = time.time()
|
|
test_case = ET.SubElement(testsuite, "testcase", name=name)
|
|
try:
|
|
check["check"]()
|
|
elapsed_time = time.time() - start_time
|
|
markdown_table += f"| {name} | ✅ |\n"
|
|
test_case.set("time", str(round(elapsed_time, 2)))
|
|
except Exception as e:
|
|
error_count += 1
|
|
elapsed_time = time.time() - start_time
|
|
markdown_table += f"| {name} | ❌ Fail: {str(e)} |\n"
|
|
test_case.set("time", str(round(elapsed_time, 2)))
|
|
error = ET.SubElement(test_case, "error", message=str(e))
|
|
error.text = traceback.format_exc()
|
|
markdown_table += "\n"
|
|
markdown_table += "In case of failure, please reach out on [discord](https://tinytapeout.com/discord) for assistance."
|
|
|
|
testsuites = ET.Element("testsuites")
|
|
testsuites.append(testsuite)
|
|
xunit_report = ET.ElementTree(testsuites)
|
|
ET.indent(xunit_report, space=" ", level=0)
|
|
xunit_report.write(f"{REPORTS_PATH}/results.xml", encoding="unicode")
|
|
|
|
with open(f"{REPORTS_PATH}/results.md", "w") as f:
|
|
f.write(markdown_table)
|
|
|
|
if gds_temp is not None:
|
|
gds_temp.close()
|
|
os.unlink(gds_temp.name)
|
|
|
|
if error_count > 0:
|
|
logging.error(f"Precheck failed for {args.gds}! 😭")
|
|
logging.error(f"See {REPORTS_PATH} for more details")
|
|
logging.error(f"Markdown report:\n{markdown_table}")
|
|
exit(1)
|
|
else:
|
|
logging.info(f"Precheck passed for {args.gds}! 🎉")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|