mirror of
https://github.com/TinyTapeout/tt-support-tools.git
synced 2026-11-03 12:46:08 +01:00
448 lines
16 KiB
Python
448 lines
16 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 brotli
|
|
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_pos,
|
|
forbidden_layers,
|
|
layer_map,
|
|
lyp_filename,
|
|
tech_names,
|
|
valid_layers,
|
|
)
|
|
|
|
PDK_ROOT = os.getenv("PDK_ROOT")
|
|
PDK_NAME = os.getenv("PDK_NAME") 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_drc(gds: str, check: str, script=f"{PDK_NAME}_mr.drc", extra_vars=[]):
|
|
logging.info(f"Running klayout {check} on {gds}")
|
|
report_file = f"{REPORTS_PATH}/drc_{check}.xml"
|
|
script_vars = [
|
|
f"{check}=true",
|
|
f"input={gds}",
|
|
f"report={report_file}",
|
|
f"report_file={report_file}",
|
|
]
|
|
klayout_args = ["klayout", "-b", "-r", f"tech-files/{script}"]
|
|
for v in script_vars + extra_vars:
|
|
klayout_args.extend(["-rd", v])
|
|
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_zero_area(gds: str):
|
|
return klayout_drc(gds, "zero_area", "zeroarea.rb.drc")
|
|
|
|
|
|
def klayout_sg13g2(gds: str):
|
|
return klayout_drc(gds, "sg13g2", "sg13g2_mr.lydrc", extra_vars=[f"in_gds={gds}"])
|
|
|
|
|
|
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_info = layers["prBoundary.boundary"]
|
|
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"prBoundary.boundary ({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_info = layers["prBoundary.boundary"]
|
|
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 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))
|
|
|
|
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}")
|
|
|
|
|
|
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 = [f"thr={os.cpu_count()}", f"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]
|
|
met4 = top.copy("test_met4")
|
|
met4.flatten()
|
|
met4.filter([layer_map[tech]["met4"]], False)
|
|
via3 = top.copy("test_via3")
|
|
via3.flatten()
|
|
via3.filter([layer_map[tech]["via3"]], False)
|
|
|
|
for pin in range(8):
|
|
x = analog_pin_pos[tech](pin, uses_3v3)
|
|
pin_over = gdstk.rectangle((x, 0), (x + 0.9, 1.0))
|
|
pin_around = gdstk.boolean(
|
|
gdstk.offset(pin_over, 0.5), gdstk.offset(pin_over, 0.1), "not"
|
|
)
|
|
|
|
via3_over = gdstk.boolean(via3.polygons, pin_over, "and")
|
|
met4_around = gdstk.boolean(met4.polygons, pin_around, "and")
|
|
connected = bool(via3_over) or bool(met4_around)
|
|
|
|
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 metal4 or via3 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 metal4 or via3 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 using metal4 or via3, 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 using metal4 or via3, 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(".gds.br"):
|
|
gds_stem = args.gds.removesuffix(".gds.br")
|
|
gds_temp = tempfile.NamedTemporaryFile(suffix=".gds", delete=False)
|
|
gds_file = gds_temp.name
|
|
logging.info(f"decompressing {args.gds} to {gds_file}")
|
|
with open(args.gds, "rb") as f:
|
|
data = f.read()
|
|
with open(gds_file, "wb") as f:
|
|
f.write(brotli.decompress(data))
|
|
else:
|
|
raise PrecheckFailure("Layout file extension is neither .gds nor .gds.br")
|
|
|
|
if args.tech not in tech_names:
|
|
raise PrecheckFailure(f"Invalid tech: {args.tech}")
|
|
tech = args.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 == "/":
|
|
raise PrecheckFailure("info.yaml not found")
|
|
yaml_file = f"{yaml_dir}/info.yaml"
|
|
yaml_data = yaml.safe_load(open(yaml_file))
|
|
logging.info("info.yaml data:" + str(yaml_data))
|
|
|
|
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")
|
|
if is_analog:
|
|
if uses_3v3:
|
|
template_def = f"../def/analog/tt_analog_{tiles}_3v3.def"
|
|
else:
|
|
template_def = f"../def/analog/tt_analog_{tiles}.def"
|
|
elif tech == "ihp-sg13g2":
|
|
template_def = f"../ihp/def/tt_block_{tiles}_pgvdd.def"
|
|
else:
|
|
template_def = f"../def/tt_block_{tiles}_pg.def"
|
|
logging.info(f"using def template {template_def}")
|
|
|
|
lef_file = gds_stem + ".lef"
|
|
verilog_file = gds_stem + ".v"
|
|
|
|
checks = [
|
|
{
|
|
"name": "Magic DRC",
|
|
"check": lambda: magic_drc(gds_file, top_module),
|
|
"tech": "sky130",
|
|
},
|
|
{
|
|
"name": "KLayout FEOL",
|
|
"check": lambda: klayout_drc(gds_file, "feol"),
|
|
"tech": "sky130",
|
|
},
|
|
{
|
|
"name": "KLayout BEOL",
|
|
"check": lambda: klayout_drc(gds_file, "beol"),
|
|
"tech": "sky130",
|
|
},
|
|
{
|
|
"name": "KLayout offgrid",
|
|
"check": lambda: klayout_drc(gds_file, "offgrid"),
|
|
"tech": "sky130",
|
|
},
|
|
{
|
|
"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 SG13G2 DRC",
|
|
"check": lambda: klayout_sg13g2(gds_file),
|
|
"tech": "ihp-sg13g2",
|
|
},
|
|
{"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),
|
|
"tech": "sky130",
|
|
},
|
|
{"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),
|
|
},
|
|
{
|
|
"name": "Analog pin check",
|
|
"check": lambda: analog_pin_check(
|
|
gds_file, tech, is_analog, uses_3v3, analog_pins, pinout
|
|
),
|
|
},
|
|
]
|
|
|
|
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 "tech" in check and check["tech"] != tech:
|
|
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()
|