diff --git a/.github/workflows/github-actions-cron-util-test.yml b/.github/workflows/github-actions-cron-util-test.yml index e2636fc7ad..0995738b48 100644 --- a/.github/workflows/github-actions-cron-util-test.yml +++ b/.github/workflows/github-actions-cron-util-test.yml @@ -4,12 +4,12 @@ on: - cron: "0 8 * * SUN" push: paths: - - 'flow/util/genElapsedTime.py' - - 'flow/test/test_genElapsedTime.py' + - 'flow/util/*.py' + - 'flow/test/test_*.py' pull_request: paths: - - 'flow/util/genElapsedTime.py' - - 'flow/test/test_genElapsedTime.py' + - 'flow/util/*.py' + - 'flow/test/test_*.py' # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/flow/Makefile b/flow/Makefile index f18ca938d7..7bccdc8307 100644 --- a/flow/Makefile +++ b/flow/Makefile @@ -195,21 +195,25 @@ $(OBJECTS_DIR)/klayout.lyt: $(KLAYOUT_TECH_FILE) $(OBJECTS_DIR)/klayout_tech.lef .PHONY: do-klayout do-klayout: -ifeq ($(KLAYOUT_ENV_VAR_IN_PATH),valid) - SC_LEF_RELATIVE_PATH="$(shell realpath --relative-to=$(RESULTS_DIR) $(SC_LEF))"; \ - OTHER_LEFS_RELATIVE_PATHS=$$(echo "$(foreach file, $(OBJECTS_DIR)/klayout_tech.lef $(ADDITIONAL_LEFS),$$(realpath --relative-to=$(RESULTS_DIR) $(file)))"); \ - sed 's,.*,'"$$SC_LEF_RELATIVE_PATH"''"$$OTHER_LEFS_RELATIVE_PATHS"',g' $(KLAYOUT_TECH_FILE) > $(OBJECTS_DIR)/klayout.lyt -else - sed 's,.*,$(foreach file, $(OBJECTS_DIR)/klayout_tech.lef $(SC_LEF) $(ADDITIONAL_LEFS),$(shell realpath --relative-to=$(RESULTS_DIR) $(file))),g' $(KLAYOUT_TECH_FILE) > $(OBJECTS_DIR)/klayout.lyt -endif - sed -i 's,.*,$(foreach file, $(FLOW_HOME)/platforms/$(PLATFORM)/*map,$(shell realpath $(file))),g' $(OBJECTS_DIR)/klayout.lyt + @mkdir -p $(dir $(OBJECTS_DIR)/klayout.lyt) + $(PYTHON_EXE) $(UTILS_DIR)/generate_klayout_tech.py \ + --template $(KLAYOUT_TECH_FILE) \ + --output $(OBJECTS_DIR)/klayout.lyt \ + --lef-files $(OBJECTS_DIR)/klayout_tech.lef $(SC_LEF) $(ADDITIONAL_LEFS) \ + --reference-dir $(RESULTS_DIR) \ + --map-files $(wildcard $(FLOW_HOME)/platforms/$(PLATFORM)/*map) $(OBJECTS_DIR)/klayout_wrap.lyt: $(KLAYOUT_TECH_FILE) $(OBJECTS_DIR)/klayout_tech.lef $(UNSET_AND_MAKE) do-klayout_wrap .PHONY: do-klayout_wrap do-klayout_wrap: - sed 's,.*,$(foreach file, $(OBJECTS_DIR)/klayout_tech.lef $(WRAP_LEFS),$(shell realpath --relative-to=$(OBJECTS_DIR)/def $(file))),g' $(KLAYOUT_TECH_FILE) > $(OBJECTS_DIR)/klayout_wrap.lyt + @mkdir -p $(dir $(OBJECTS_DIR)/klayout_wrap.lyt) + $(PYTHON_EXE) $(UTILS_DIR)/generate_klayout_tech.py \ + --template $(KLAYOUT_TECH_FILE) \ + --output $(OBJECTS_DIR)/klayout_wrap.lyt \ + --lef-files $(OBJECTS_DIR)/klayout_tech.lef $(WRAP_LEFS) \ + --reference-dir $(OBJECTS_DIR)/def $(WRAPPED_LEFS): mkdir -p $(OBJECTS_DIR)/lef $(OBJECTS_DIR)/def @@ -627,7 +631,7 @@ final: finish .PHONY: do-finish do-finish: - $(UNSET_AND_MAKE) do-6_1_fill do-6_1_fill.sdc do-6_final.sdc do-6_report do-gds elapsed + $(UNSET_AND_MAKE) do-6_1_fill do-6_1_fill.sdc do-6_final.sdc do-6_report elapsed .PHONY: generate_abstract generate_abstract: $(RESULTS_DIR)/6_final.gds $(RESULTS_DIR)/6_final.def $(RESULTS_DIR)/6_final.v $(RESULTS_DIR)/6_final.sdc @@ -643,6 +647,17 @@ do-generate_abstract: clean_abstract: rm -f $(RESULTS_DIR)/$(DESIGN_NAME).lib $(RESULTS_DIR)/$(DESIGN_NAME).lef +.PHONY: check-klayout +check-klayout: + @if [ -z "$(KLAYOUT_CMD)" ]; then \ + echo "Error: KLayout not found. Install KLayout or set KLAYOUT_CMD."; \ + echo "Hint: KLayout is needed for GDS/DRC/LVS targets."; \ + exit 1; \ + fi + +.PHONY: gds +gds: $(GDS_FINAL_FILE) + # Merge wrapped macros using Klayout #------------------------------------------------------------------------------- $(WRAPPED_GDSOAS): $(OBJECTS_DIR)/klayout_wrap.lyt $(WRAPPED_LEFS) @@ -658,7 +673,7 @@ $(WRAPPED_GDSOAS): $(OBJECTS_DIR)/klayout_wrap.lyt $(WRAPPED_LEFS) # Merge GDS using Klayout #------------------------------------------------------------------------------- -$(GDS_MERGED_FILE): $(RESULTS_DIR)/6_final.def $(OBJECTS_DIR)/klayout.lyt $(GDSOAS_FILES) $(WRAPPED_GDSOAS) $(SEAL_GDSOAS) +$(GDS_MERGED_FILE): check-klayout $(RESULTS_DIR)/6_final.def $(OBJECTS_DIR)/klayout.lyt $(GDSOAS_FILES) $(WRAPPED_GDSOAS) $(SEAL_GDSOAS) $(UNSET_AND_MAKE) do-gds-merged .PHONY: do-gds-merged @@ -768,7 +783,7 @@ nuke: clean_test clean_issues # DEF/GDS/OAS viewer shortcuts #------------------------------------------------------------------------------- .PHONY: $(foreach file,$(RESULTS_DEF) $(RESULTS_GDS) $(RESULTS_OAS),klayout_$(file)) -$(foreach file,$(RESULTS_DEF) $(RESULTS_GDS) $(RESULTS_OAS),klayout_$(file)): klayout_%: $(OBJECTS_DIR)/klayout.lyt +$(foreach file,$(RESULTS_DEF) $(RESULTS_GDS) $(RESULTS_OAS),klayout_$(file)): klayout_%: check-klayout $(OBJECTS_DIR)/klayout.lyt $(SCRIPTS_DIR)/klayout.sh -nn $(OBJECTS_DIR)/klayout.lyt $(RESULTS_DIR)/$* $(eval $(call OPEN_GUI_SHORTCUT,synth,1_synth.odb)) diff --git a/flow/docs/KLayoutOptionalDependency.md b/flow/docs/KLayoutOptionalDependency.md new file mode 100644 index 0000000000..c469160a27 --- /dev/null +++ b/flow/docs/KLayoutOptionalDependency.md @@ -0,0 +1,59 @@ +# KLayout as an Optional Dependency + +KLayout is only required for GDS/OAS stream generation, DRC, and LVS +verification. All other ORFS functionality — synthesis, floorplanning, +placement, CTS, routing, timing reports, and abstract generation — works +without KLayout installed. + +## Makefile Targets + +| Target | Requires KLayout | Description | +|---|---|---| +| `make finish` | Yes | Complete flow including GDS generation | +| `make gds` | Yes | Generate GDS/OAS from finished design | +| `make drc` | Yes | Run DRC checks (requires GDS) | +| `make lvs` | Yes | Run LVS checks (requires GDS) | +| `make gallery` | Yes | Generate layout screenshots | +| `make klayout_` | Yes | Open result in KLayout viewer | +| `make generate_abstract` | No | Generate LEF/LIB abstracts | + +A `check-klayout` guard produces a clear error message when KLayout is +missing and a KLayout-dependent target is invoked: + +``` +Error: KLayout not found. Install KLayout or set KLAYOUT_CMD. +Hint: KLayout is needed for GDS/DRC/LVS targets. +``` + +## bazel-orfs Integration + +bazel-orfs uses the `do-` prefixed targets which bypass Make's dependency +management. `do-finish` / `do-final` only run the finish stage recipe +itself, while `make finish` also pulls in the GDS target as a Make +dependency. `do-gds` runs GDS generation separately (requires KLayout). + +An `orfs_gds()` Bazel rule can call `do-gds` independently from +`orfs_flow()`, making KLayout an optional toolchain dependency configured +in `MODULE.bazel`. + +## KLayout Tech File Generation + +The `do-klayout` and `do-klayout_wrap` targets generate `.lyt` technology +files by substituting LEF and map file paths into platform templates. +This is implemented in `util/generate_klayout_tech.py` using stdlib XML +processing — no KLayout dependency required. + +## Testing Without KLayout + +Unit tests for all KLayout-related Python scripts use `unittest.mock` to +mock the `pya` API: + +``` +cd flow/test +python -m unittest test_generate_klayout_tech test_def2stream test_convertDrc +``` + +These tests cover: +- `.lyt` tech file generation (`test_generate_klayout_tech.py`) +- DEF-to-GDS merging logic (`test_def2stream.py`) +- DRC report conversion (`test_convertDrc.py`) diff --git a/flow/test/test_convertDrc.py b/flow/test/test_convertDrc.py new file mode 100644 index 0000000000..87f5894dcc --- /dev/null +++ b/flow/test/test_convertDrc.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 + +import unittest +from unittest.mock import MagicMock, patch +import sys +import os + +# Mock pya before importing convertDrc since it imports pya at module level +sys.modules["pya"] = MagicMock() + +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "util")) + +# convertDrc uses a global `in_drc` set by klayout -rd, so we must set it +import builtins + +builtins.in_drc = "/tmp/test.drc" +builtins.out_file = "/tmp/test.json" + +# Now we can import - but the module-level code tries to use pya.Application +# We need to handle this by patching before import +import importlib + +# Import just the convert_drc function by reading the source +import types + +_util_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "util") +_src_path = os.path.join(_util_dir, "convertDrc.py") + +# Load only the convert_drc function, not the module-level klayout code +with open(_src_path) as f: + source = f.read() + +# Extract just the function definition +import textwrap +import re as _re + +# Parse out the convert_drc function +_func_start = source.index("def convert_drc(rdb):") +_func_end = source.index("\n\napp = pya.Application") +_func_source = source[_func_start:_func_end] + +# Create a module with just the function +_mod = types.ModuleType("convertDrc_test") +_mod.__dict__["os"] = os +_mod.__dict__["in_drc"] = "/tmp/test.drc" +exec(compile(_func_source, _src_path, "exec"), _mod.__dict__) +convert_drc = _mod.convert_drc + + +def make_mock_point(x, y): + p = MagicMock() + p.x = x + p.y = y + return p + + +def make_mock_edge(p1_x, p1_y, p2_x, p2_y): + edge = MagicMock() + edge.p1 = make_mock_point(p1_x, p1_y) + edge.p2 = make_mock_point(p2_x, p2_y) + return edge + + +def make_box_value(left, bottom, right, top): + value = MagicMock() + value.is_box.return_value = True + value.is_edge.return_value = False + value.is_edge_pair.return_value = False + value.is_polygon.return_value = False + value.is_path.return_value = False + value.is_text.return_value = False + value.is_string.return_value = False + box = MagicMock() + box.left = left + box.bottom = bottom + box.right = right + box.top = top + value.box.return_value = box + return value + + +def make_edge_value(p1_x, p1_y, p2_x, p2_y): + value = MagicMock() + value.is_box.return_value = False + value.is_edge.return_value = True + value.is_edge_pair.return_value = False + value.is_polygon.return_value = False + value.is_path.return_value = False + value.is_text.return_value = False + value.is_string.return_value = False + value.edge.return_value = make_mock_edge(p1_x, p1_y, p2_x, p2_y) + return value + + +def make_text_value(text): + value = MagicMock() + value.is_box.return_value = False + value.is_edge.return_value = False + value.is_edge_pair.return_value = False + value.is_polygon.return_value = False + value.is_path.return_value = False + value.is_text.return_value = True + value.is_string.return_value = False + value.text.return_value = text + return value + + +def make_mock_item(values, is_visited=False, tags_str="", comment=None): + item = MagicMock() + item.is_visited.return_value = is_visited + item.tags_str = tags_str + item.each_value.return_value = iter(values) + if comment is not None: + item.comment = comment + else: + # Remove hasattr for comment + del item.comment + return item + + +def make_mock_category(name, description, rdb_id, num_items, items): + cat = MagicMock() + cat.name.return_value = name + cat.description = description + cat.rdb_id.return_value = rdb_id + cat.num_items.return_value = num_items + return cat, items + + +class TestConvertDrc(unittest.TestCase): + def test_empty_rdb(self): + rdb = MagicMock() + rdb.each_category.return_value = iter([]) + + result = convert_drc(rdb) + + self.assertEqual(result["source"], os.path.abspath("/tmp/test.drc")) + self.assertEqual(result["category"], {}) + + def test_empty_category_skipped(self): + cat = MagicMock() + cat.num_items.return_value = 0 + + rdb = MagicMock() + rdb.each_category.return_value = iter([cat]) + + result = convert_drc(rdb) + self.assertEqual(result["category"], {}) + + def test_box_violation(self): + box_val = make_box_value(100, 200, 300, 400) + item = make_mock_item([box_val]) + + cat = MagicMock() + cat.name.return_value = "metal1.min_width" + cat.description = "Minimum width violation" + cat.rdb_id.return_value = 1 + cat.num_items.return_value = 1 + + rdb = MagicMock() + rdb.each_category.return_value = iter([cat]) + rdb.each_item_per_category.return_value = iter([item]) + + result = convert_drc(rdb) + + violations = result["category"]["metal1.min_width"]["violations"] + self.assertEqual(len(violations), 1) + self.assertEqual(len(violations[0]["shape"]), 1) + shape = violations[0]["shape"][0] + self.assertEqual(shape["type"], "box") + self.assertEqual(shape["points"][0], {"x": 100, "y": 200}) + self.assertEqual(shape["points"][1], {"x": 300, "y": 400}) + + def test_edge_violation(self): + edge_val = make_edge_value(10, 20, 30, 40) + item = make_mock_item([edge_val]) + + cat = MagicMock() + cat.name.return_value = "metal1.spacing" + cat.description = "Spacing violation" + cat.rdb_id.return_value = 2 + cat.num_items.return_value = 1 + + rdb = MagicMock() + rdb.each_category.return_value = iter([cat]) + rdb.each_item_per_category.return_value = iter([item]) + + result = convert_drc(rdb) + + violations = result["category"]["metal1.spacing"]["violations"] + shape = violations[0]["shape"][0] + self.assertEqual(shape["type"], "line") + self.assertEqual(shape["points"][0], {"x": 10, "y": 20}) + self.assertEqual(shape["points"][1], {"x": 30, "y": 40}) + + def test_waived_violation(self): + box_val = make_box_value(0, 0, 10, 10) + item = make_mock_item([box_val], tags_str="waived") + + cat = MagicMock() + cat.name.return_value = "rule1" + cat.description = "Rule 1" + cat.rdb_id.return_value = 1 + cat.num_items.return_value = 1 + + rdb = MagicMock() + rdb.each_category.return_value = iter([cat]) + rdb.each_item_per_category.return_value = iter([item]) + + result = convert_drc(rdb) + + violation = result["category"]["rule1"]["violations"][0] + self.assertTrue(violation["waived"]) + + def test_text_in_comment(self): + text_val = make_text_value("error detail") + item = make_mock_item([text_val]) + + cat = MagicMock() + cat.name.return_value = "rule1" + cat.description = "Rule 1" + cat.rdb_id.return_value = 1 + cat.num_items.return_value = 1 + + rdb = MagicMock() + rdb.each_category.return_value = iter([cat]) + rdb.each_item_per_category.return_value = iter([item]) + + result = convert_drc(rdb) + + violation = result["category"]["rule1"]["violations"][0] + self.assertEqual(violation["comment"], "error detail") + + def test_comment_with_text(self): + text_val = make_text_value("extra info") + item = make_mock_item([text_val], comment="base comment") + + cat = MagicMock() + cat.name.return_value = "rule1" + cat.description = "Rule 1" + cat.rdb_id.return_value = 1 + cat.num_items.return_value = 1 + + rdb = MagicMock() + rdb.each_category.return_value = iter([cat]) + rdb.each_item_per_category.return_value = iter([item]) + + result = convert_drc(rdb) + + violation = result["category"]["rule1"]["violations"][0] + self.assertEqual(violation["comment"], "base comment: extra info") + + +if __name__ == "__main__": + unittest.main() diff --git a/flow/test/test_def2stream.py b/flow/test/test_def2stream.py new file mode 100644 index 0000000000..db9e88de50 --- /dev/null +++ b/flow/test/test_def2stream.py @@ -0,0 +1,467 @@ +#!/usr/bin/env python3 + +import unittest +from unittest.mock import MagicMock, patch, call +import sys +import os + +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "util")) + +import def2stream + + +def make_mock_cell(name, cell_index=0, is_empty=False, parent_cells=1): + """Create a mock cell object.""" + cell = MagicMock() + cell.name = name + cell.cell_index.return_value = cell_index + cell.is_empty.return_value = is_empty + cell.parent_cells.return_value = parent_cells + return cell + + +def make_mock_pya(cells_before_read=None, cells_after_read=None, top_only_cells=None): + """Create a mock pya module with configurable cell behavior. + + Args: + cells_before_read: Cells in main_layout before reading DEF. + cells_after_read: Cells in main_layout after reading DEF/GDS. + top_only_cells: Cells in top_only_layout after copy_tree. + """ + pya_mod = MagicMock() + + # Technology mock + tech = MagicMock() + pya_mod.Technology.return_value = tech + + # Main layout mock + main_layout = MagicMock() + pya_mod.Layout.side_effect = [main_layout] + + if cells_before_read is None: + cells_before_read = [] + if cells_after_read is None: + cells_after_read = [] + + # each_cell returns different results before and after read + main_layout.each_cell.side_effect = [ + iter(cells_before_read), # first call: before reading DEF + iter(cells_after_read), # second call: clearing non-top cells + ] + + # top_only_layout is the second Layout() call + top_only_layout = MagicMock() + + if top_only_cells is None: + top_only_cells = [] + + # top_only each_cell called twice: missing cell check and orphan check + top_only_layout.each_cell.side_effect = [ + iter(top_only_cells), # missing cell check + iter(top_only_cells), # orphan cell check + ] + + # Override Layout side_effect to return both layouts + pya_mod.Layout.side_effect = [main_layout, top_only_layout] + + top_cell = MagicMock() + top_cell.name = "test_design" + top_only_layout.create_cell.return_value = top_cell + top_only_layout.top_cell.return_value = top_cell + top_only_layout.top_cells.return_value = [] + + return pya_mod, main_layout, top_only_layout, top_cell + + +class TestMergeGdsBasic(unittest.TestCase): + def test_no_errors_clean_design(self): + """A clean design with no missing/orphan cells should return 0 errors.""" + top = make_mock_cell("test_design", cell_index=0) + + pya_mod, main_layout, top_only_layout, _ = make_mock_pya( + cells_before_read=[], + cells_after_read=[top], + top_only_cells=[], + ) + + main_layout.cell.return_value = top + + errors = def2stream.merge_gds( + pya_mod=pya_mod, + tech_file="/tmp/test.lyt", + layer_map="", + in_def="/tmp/test.def", + design_name="test_design", + in_files="/tmp/cells.gds", + seal_file="", + out_file="/tmp/out.gds", + ) + + self.assertEqual(errors, 0) + top_only_layout.write.assert_called_once_with("/tmp/out.gds") + + def test_layer_map_applied(self): + """When layer_map is non-empty, it should be set on layout options.""" + top = make_mock_cell("test_design", cell_index=0) + + pya_mod, main_layout, _, _ = make_mock_pya( + cells_before_read=[], + cells_after_read=[top], + top_only_cells=[], + ) + main_layout.cell.return_value = top + + tech = pya_mod.Technology.return_value + + def2stream.merge_gds( + pya_mod=pya_mod, + tech_file="/tmp/test.lyt", + layer_map="/tmp/layer.map", + in_def="/tmp/test.def", + design_name="test_design", + in_files="", + seal_file="", + out_file="/tmp/out.gds", + ) + + self.assertEqual( + tech.load_layout_options.lefdef_config.map_file, "/tmp/layer.map" + ) + + def test_empty_layer_map_not_applied(self): + """When layer_map is empty, map_file should not be set.""" + top = make_mock_cell("test_design", cell_index=0) + + pya_mod, main_layout, _, _ = make_mock_pya( + cells_before_read=[], + cells_after_read=[top], + top_only_cells=[], + ) + main_layout.cell.return_value = top + + tech = pya_mod.Technology.return_value + original_map = tech.load_layout_options.lefdef_config.map_file + + def2stream.merge_gds( + pya_mod=pya_mod, + tech_file="/tmp/test.lyt", + layer_map="", + in_def="/tmp/test.def", + design_name="test_design", + in_files="", + seal_file="", + out_file="/tmp/out.gds", + ) + + self.assertEqual(tech.load_layout_options.lefdef_config.map_file, original_map) + + +class TestCellClearing(unittest.TestCase): + def test_non_top_cells_cleared(self): + """Non-top cells (not VIA_ or _DEF_FILL) should be cleared.""" + top = make_mock_cell("test_design", cell_index=0) + filler = make_mock_cell("FILLER_cell", cell_index=1) + + pya_mod, main_layout, _, _ = make_mock_pya( + cells_before_read=[], + cells_after_read=[top, filler], + top_only_cells=[], + ) + main_layout.cell.return_value = top + + def2stream.merge_gds( + pya_mod=pya_mod, + tech_file="/tmp/test.lyt", + layer_map="", + in_def="/tmp/test.def", + design_name="test_design", + in_files="", + seal_file="", + out_file="/tmp/out.gds", + ) + + filler.clear.assert_called_once() + + def test_via_cells_preserved(self): + """Cells starting with VIA_ should NOT be cleared.""" + top = make_mock_cell("test_design", cell_index=0) + via = make_mock_cell("VIA_M1M2", cell_index=1) + + pya_mod, main_layout, _, _ = make_mock_pya( + cells_before_read=[], + cells_after_read=[top, via], + top_only_cells=[], + ) + main_layout.cell.return_value = top + + def2stream.merge_gds( + pya_mod=pya_mod, + tech_file="/tmp/test.lyt", + layer_map="", + in_def="/tmp/test.def", + design_name="test_design", + in_files="", + seal_file="", + out_file="/tmp/out.gds", + ) + + via.clear.assert_not_called() + + def test_def_fill_cells_preserved(self): + """Cells ending with _DEF_FILL should NOT be cleared.""" + top = make_mock_cell("test_design", cell_index=0) + fill = make_mock_cell("some_DEF_FILL", cell_index=2) + + pya_mod, main_layout, _, _ = make_mock_pya( + cells_before_read=[], + cells_after_read=[top, fill], + top_only_cells=[], + ) + main_layout.cell.return_value = top + + def2stream.merge_gds( + pya_mod=pya_mod, + tech_file="/tmp/test.lyt", + layer_map="", + in_def="/tmp/test.def", + design_name="test_design", + in_files="", + seal_file="", + out_file="/tmp/out.gds", + ) + + fill.clear.assert_not_called() + + +class TestMissingCells(unittest.TestCase): + def test_empty_cell_is_error(self): + """An empty cell without GDS_ALLOW_EMPTY should count as an error.""" + missing = make_mock_cell( + "missing_gds", cell_index=1, is_empty=True, parent_cells=1 + ) + + top = make_mock_cell("test_design", cell_index=0) + + pya_mod, main_layout, _, _ = make_mock_pya( + cells_before_read=[], + cells_after_read=[top], + top_only_cells=[missing], + ) + main_layout.cell.return_value = top + + errors = def2stream.merge_gds( + pya_mod=pya_mod, + tech_file="/tmp/test.lyt", + layer_map="", + in_def="/tmp/test.def", + design_name="test_design", + in_files="", + seal_file="", + out_file="/tmp/out.gds", + ) + + self.assertEqual(errors, 1) + + def test_allow_empty_regex_suppresses_error(self): + """GDS_ALLOW_EMPTY regex should suppress errors for matching cells.""" + missing = make_mock_cell( + "pad_io_cell", cell_index=1, is_empty=True, parent_cells=1 + ) + + top = make_mock_cell("test_design", cell_index=0) + + pya_mod, main_layout, _, _ = make_mock_pya( + cells_before_read=[], + cells_after_read=[top], + top_only_cells=[missing], + ) + main_layout.cell.return_value = top + + errors = def2stream.merge_gds( + pya_mod=pya_mod, + tech_file="/tmp/test.lyt", + layer_map="", + in_def="/tmp/test.def", + design_name="test_design", + in_files="", + seal_file="", + out_file="/tmp/out.gds", + allow_empty="pad_.*", + ) + + self.assertEqual(errors, 0) + + def test_allow_empty_regex_no_match_still_errors(self): + """GDS_ALLOW_EMPTY regex should not suppress non-matching cells.""" + missing = make_mock_cell( + "other_cell", cell_index=1, is_empty=True, parent_cells=1 + ) + + top = make_mock_cell("test_design", cell_index=0) + + pya_mod, main_layout, _, _ = make_mock_pya( + cells_before_read=[], + cells_after_read=[top], + top_only_cells=[missing], + ) + main_layout.cell.return_value = top + + errors = def2stream.merge_gds( + pya_mod=pya_mod, + tech_file="/tmp/test.lyt", + layer_map="", + in_def="/tmp/test.def", + design_name="test_design", + in_files="", + seal_file="", + out_file="/tmp/out.gds", + allow_empty="pad_.*", + ) + + self.assertEqual(errors, 1) + + +class TestOrphanCells(unittest.TestCase): + def test_orphan_cell_is_error(self): + """A cell with no parents (orphan) should count as an error.""" + orphan = make_mock_cell( + "orphan_cell", cell_index=1, is_empty=False, parent_cells=0 + ) + + top = make_mock_cell("test_design", cell_index=0) + + pya_mod, main_layout, _, _ = make_mock_pya( + cells_before_read=[], + cells_after_read=[top], + top_only_cells=[orphan], + ) + main_layout.cell.return_value = top + + errors = def2stream.merge_gds( + pya_mod=pya_mod, + tech_file="/tmp/test.lyt", + layer_map="", + in_def="/tmp/test.def", + design_name="test_design", + in_files="", + seal_file="", + out_file="/tmp/out.gds", + ) + + self.assertEqual(errors, 1) + + def test_top_cell_not_orphan(self): + """The top cell itself should not be counted as an orphan.""" + top = make_mock_cell( + "test_design", cell_index=0, is_empty=False, parent_cells=0 + ) + + pya_mod, main_layout, _, _ = make_mock_pya( + cells_before_read=[], + cells_after_read=[top], + top_only_cells=[top], + ) + main_layout.cell.return_value = top + + errors = def2stream.merge_gds( + pya_mod=pya_mod, + tech_file="/tmp/test.lyt", + layer_map="", + in_def="/tmp/test.def", + design_name="test_design", + in_files="", + seal_file="", + out_file="/tmp/out.gds", + ) + + self.assertEqual(errors, 0) + + +class TestSealFile(unittest.TestCase): + def test_seal_file_merged(self): + """When seal_file is provided, seal cells should be merged.""" + top = make_mock_cell("test_design", cell_index=0) + + pya_mod, main_layout, top_only_layout, top_cell = make_mock_pya( + cells_before_read=[], + cells_after_read=[top], + top_only_cells=[], + ) + main_layout.cell.return_value = top + + seal_cell = MagicMock() + seal_cell.name = "seal_ring" + seal_cell.cell_index.return_value = 5 + # top_cells returns original top + seal after reading seal file + top_only_layout.top_cells.return_value = [top_cell, seal_cell] + + errors = def2stream.merge_gds( + pya_mod=pya_mod, + tech_file="/tmp/test.lyt", + layer_map="", + in_def="/tmp/test.def", + design_name="test_design", + in_files="", + seal_file="/tmp/seal.gds", + out_file="/tmp/out.gds", + ) + + self.assertEqual(errors, 0) + top_only_layout.read.assert_called_once_with("/tmp/seal.gds") + pya_mod.CellInstArray.assert_called_once_with(5, pya_mod.Trans.return_value) + + def test_no_seal_file(self): + """When seal_file is empty, no seal merging should happen.""" + top = make_mock_cell("test_design", cell_index=0) + + pya_mod, main_layout, top_only_layout, _ = make_mock_pya( + cells_before_read=[], + cells_after_read=[top], + top_only_cells=[], + ) + main_layout.cell.return_value = top + + def2stream.merge_gds( + pya_mod=pya_mod, + tech_file="/tmp/test.lyt", + layer_map="", + in_def="/tmp/test.def", + design_name="test_design", + in_files="", + seal_file="", + out_file="/tmp/out.gds", + ) + + top_only_layout.read.assert_not_called() + + +class TestGdsFileMerging(unittest.TestCase): + def test_multiple_gds_files_read(self): + """All space-separated GDS files should be read into main_layout.""" + top = make_mock_cell("test_design", cell_index=0) + + pya_mod, main_layout, _, _ = make_mock_pya( + cells_before_read=[], + cells_after_read=[top], + top_only_cells=[], + ) + main_layout.cell.return_value = top + + def2stream.merge_gds( + pya_mod=pya_mod, + tech_file="/tmp/test.lyt", + layer_map="", + in_def="/tmp/test.def", + design_name="test_design", + in_files="/tmp/a.gds /tmp/b.gds /tmp/c.gds", + seal_file="", + out_file="/tmp/out.gds", + ) + + # read is called for DEF + 3 GDS files = 4 total + read_calls = main_layout.read.call_args_list + self.assertEqual(len(read_calls), 4) # 1 DEF + 3 GDS + + +if __name__ == "__main__": + unittest.main() diff --git a/flow/test/test_generate_klayout_tech.py b/flow/test/test_generate_klayout_tech.py new file mode 100644 index 0000000000..22c6642127 --- /dev/null +++ b/flow/test/test_generate_klayout_tech.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 + +import unittest +import os +import sys +import tempfile + +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "util")) + +import generate_klayout_tech + +MINIMAL_LYT = """\ + + + Test + + + ./original.lef + + + +""" + +LYT_WITH_MAP = """\ + + + Test + + + ./original.lef + original.map + + + +""" + +LYT_WITH_EMPTY_MAP = """\ + + + Test + + + ./original.lef + + + + +""" + + +class TestReplaceLefFiles(unittest.TestCase): + def test_single_lef(self): + result = generate_klayout_tech.replace_lef_files(MINIMAL_LYT, ["tech.lef"]) + self.assertIn("tech.lef", result) + self.assertNotIn("original.lef", result) + + def test_multiple_lefs(self): + result = generate_klayout_tech.replace_lef_files( + MINIMAL_LYT, ["tech.lef", "sc.lef", "extra.lef"] + ) + self.assertIn( + "tech.lef" + "sc.lef" + "extra.lef", + result, + ) + self.assertNotIn("original.lef", result) + + def test_empty_lefs(self): + result = generate_klayout_tech.replace_lef_files(MINIMAL_LYT, []) + self.assertNotIn("original.lef", result) + # Empty replacement removes the element content + self.assertNotIn("", result) + + +class TestReplaceMapFiles(unittest.TestCase): + def test_replace_existing_map(self): + result = generate_klayout_tech.replace_map_files( + LYT_WITH_MAP, ["/abs/path/layer.map"] + ) + self.assertIn("/abs/path/layer.map", result) + self.assertNotIn("original.map", result) + + def test_replace_empty_map(self): + result = generate_klayout_tech.replace_map_files( + LYT_WITH_EMPTY_MAP, ["/abs/path/layer.map"] + ) + self.assertIn("/abs/path/layer.map", result) + + def test_no_map_files_noop(self): + result = generate_klayout_tech.replace_map_files(LYT_WITH_MAP, []) + self.assertEqual(result, LYT_WITH_MAP) + + def test_no_map_element_in_template(self): + result = generate_klayout_tech.replace_map_files(MINIMAL_LYT, ["/some/map"]) + # No map element to replace, content unchanged + self.assertEqual(result, MINIMAL_LYT) + + +class TestGenerateKlayoutTech(unittest.TestCase): + def setUp(self): + self.tmp_dir = tempfile.TemporaryDirectory() + self.template = os.path.join(self.tmp_dir.name, "test.lyt") + self.output = os.path.join(self.tmp_dir.name, "output.lyt") + self.results_dir = os.path.join(self.tmp_dir.name, "results") + os.makedirs(self.results_dir, exist_ok=True) + + def tearDown(self): + self.tmp_dir.cleanup() + + def test_basic_generation(self): + with open(self.template, "w") as f: + f.write(MINIMAL_LYT) + + lef_path = os.path.join(self.tmp_dir.name, "tech.lef") + with open(lef_path, "w") as f: + f.write("") + + generate_klayout_tech.generate_klayout_tech( + template_lyt=self.template, + output_lyt=self.output, + lef_files=[lef_path], + reference_dir=self.results_dir, + map_files=[], + use_relative_paths=True, + ) + + with open(self.output) as f: + content = f.read() + + self.assertIn("", content) + self.assertNotIn("original.lef", content) + # Path should be relative to results_dir + expected_rel = os.path.relpath( + os.path.realpath(lef_path), + os.path.realpath(self.results_dir), + ) + self.assertIn(expected_rel, content) + + def test_with_map_files(self): + with open(self.template, "w") as f: + f.write(LYT_WITH_MAP) + + lef_path = os.path.join(self.tmp_dir.name, "tech.lef") + map_path = os.path.join(self.tmp_dir.name, "layer.map") + for p in [lef_path, map_path]: + with open(p, "w") as f: + f.write("") + + generate_klayout_tech.generate_klayout_tech( + template_lyt=self.template, + output_lyt=self.output, + lef_files=[lef_path], + reference_dir=self.results_dir, + map_files=[map_path], + use_relative_paths=False, + ) + + with open(self.output) as f: + content = f.read() + + self.assertIn(os.path.realpath(map_path), content) + self.assertNotIn("original.map", content) + + def test_multiple_lef_files(self): + with open(self.template, "w") as f: + f.write(MINIMAL_LYT) + + lef_files = [] + for name in ["tech.lef", "sc.lef", "extra.lef"]: + path = os.path.join(self.tmp_dir.name, name) + with open(path, "w") as f: + f.write("") + lef_files.append(path) + + generate_klayout_tech.generate_klayout_tech( + template_lyt=self.template, + output_lyt=self.output, + lef_files=lef_files, + reference_dir=self.results_dir, + map_files=[], + use_relative_paths=True, + ) + + with open(self.output) as f: + content = f.read() + + # Should have three lef-files elements + self.assertEqual(content.count(""), 3) + + +class TestRealPlatformLyt(unittest.TestCase): + """Test against actual platform .lyt files to catch regressions.""" + + PLATFORMS_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "platforms" + ) + + def _test_platform(self, lyt_path): + if not os.path.exists(lyt_path): + self.skipTest(f"{lyt_path} not found") + + with open(lyt_path) as f: + content = f.read() + + result = generate_klayout_tech.replace_lef_files( + content, ["replaced_tech.lef", "replaced_sc.lef"] + ) + self.assertIn("replaced_tech.lef", result) + self.assertIn("replaced_sc.lef", result) + # Original lef-files content should be gone + self.assertNotIn("NangateOpenCellLibrary", result) + self.assertNotIn("asap7_tech", result) + + def test_nangate45(self): + self._test_platform( + os.path.join(self.PLATFORMS_DIR, "nangate45", "FreePDK45.lyt") + ) + + def test_asap7(self): + self._test_platform( + os.path.join(self.PLATFORMS_DIR, "asap7", "KLayout", "asap7.lyt") + ) + + def test_sky130hd(self): + self._test_platform( + os.path.join(self.PLATFORMS_DIR, "sky130hd", "sky130hd.lyt") + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/flow/util/def2stream.py b/flow/util/def2stream.py index 5062474f9e..f6530d3db3 100644 --- a/flow/util/def2stream.py +++ b/flow/util/def2stream.py @@ -1,100 +1,151 @@ -import pya +try: + import pya +except ImportError: + pya = None + import re -import json -import copy import sys import os -errors = 0 - -# Load technology file -tech = pya.Technology() -tech.load(tech_file) -layoutOptions = tech.load_layout_options -if len(layer_map) > 0: - layoutOptions.lefdef_config.map_file = layer_map - -# Load def file -main_layout = pya.Layout() -print("[INFO] Reporting cells prior to loading DEF ...") -for i in main_layout.each_cell(): - print("[INFO] '{0}'".format(i.name)) - -main_layout.read(in_def, layoutOptions) - -# Clear cells -top_cell_index = main_layout.cell(design_name).cell_index() - -# remove orphan cell BUT preserve cell with VIA_ -# - KLayout is prepending VIA_ when reading DEF that instantiates LEF's via -for i in main_layout.each_cell(): - if i.cell_index() != top_cell_index: - if not i.name.startswith("VIA_") and not i.name.endswith("_DEF_FILL"): - i.clear() - -# Load in the gds to merge -for fil in in_files.split(): - print("\t{0}".format(fil)) - main_layout.read(fil) - -# Copy the top level only to a new layout -top_only_layout = pya.Layout() -top_only_layout.dbu = main_layout.dbu -top = top_only_layout.create_cell(design_name) -top.copy_tree(main_layout.cell(design_name)) - -missing_cell = False -allow_empty = os.environ.get("GDS_ALLOW_EMPTY", "") -regex = re.compile(allow_empty) if allow_empty else None - -if allow_empty: - print(f"[INFO] GDS_ALLOW_EMPTY={allow_empty}") - -for i in top_only_layout.each_cell(): - if i.is_empty(): - missing_cell = True - if regex is not None and regex.match(i.name): - print( - "[WARNING] LEF Cell '{0}' ignored. Matches GDS_ALLOW_EMPTY.".format( - i.name - ) - ) - else: - print( - "[ERROR] LEF Cell '{0}' has no matching GDS/OAS cell." - " Cell will be empty.".format(i.name) - ) - errors += 1 -if not missing_cell: - print("[INFO] All LEF cells have matching GDS/OAS cells") +def merge_gds( + pya_mod, + tech_file, + layer_map, + in_def, + design_name, + in_files, + seal_file, + out_file, + allow_empty="", +): + """Merge DEF and GDS/OAS files into a single stream file. + + Args: + pya_mod: The pya module (klayout Python API). + tech_file: Path to klayout technology file. + layer_map: Path to layer map file (empty string if none). + in_def: Path to input DEF file. + design_name: Top-level design name. + in_files: Space-separated string of GDS/OAS files to merge. + seal_file: Path to seal ring GDS/OAS file (empty string if none). + out_file: Path to output GDS/OAS file. + allow_empty: Regex pattern for cells allowed to be empty. + + Returns: + Number of errors encountered. + """ + errors = 0 + + # Load technology file + tech = pya_mod.Technology() + tech.load(tech_file) + layout_options = tech.load_layout_options + if len(layer_map) > 0: + layout_options.lefdef_config.map_file = layer_map + + # Load def file + main_layout = pya_mod.Layout() + print("[INFO] Reporting cells prior to loading DEF ...") + for i in main_layout.each_cell(): + print("[INFO] '{0}'".format(i.name)) + + main_layout.read(in_def, layout_options) + + # Clear cells + top_cell_index = main_layout.cell(design_name).cell_index() + + # remove orphan cell BUT preserve cell with VIA_ + # - KLayout is prepending VIA_ when reading DEF that instantiates LEF's via + for i in main_layout.each_cell(): + if i.cell_index() != top_cell_index: + if not i.name.startswith("VIA_") and not i.name.endswith("_DEF_FILL"): + i.clear() + + # Load in the gds to merge + for fil in in_files.split(): + print("\t{0}".format(fil)) + main_layout.read(fil) + + # Copy the top level only to a new layout + top_only_layout = pya_mod.Layout() + top_only_layout.dbu = main_layout.dbu + top = top_only_layout.create_cell(design_name) + top.copy_tree(main_layout.cell(design_name)) + + missing_cell = False + regex = re.compile(allow_empty) if allow_empty else None + + if allow_empty: + print(f"[INFO] GDS_ALLOW_EMPTY={allow_empty}") + + for i in top_only_layout.each_cell(): + if i.is_empty(): + missing_cell = True + if regex is not None and regex.match(i.name): + print( + "[WARNING] LEF Cell '{0}' ignored. Matches GDS_ALLOW_EMPTY.".format( + i.name + ) + ) + else: + print( + "[ERROR] LEF Cell '{0}' has no matching GDS/OAS cell." + " Cell will be empty.".format(i.name) + ) + errors += 1 -orphan_cell = False -for i in top_only_layout.each_cell(): - if i.name != design_name and i.parent_cells() == 0: - orphan_cell = True - print("[ERROR] Found orphan cell '{0}'".format(i.name)) - errors += 1 + if not missing_cell: + print("[INFO] All LEF cells have matching GDS/OAS cells") -if not orphan_cell: - print("[INFO] No orphan cells in the final layout") + orphan_cell = False + for i in top_only_layout.each_cell(): + if i.name != design_name and i.parent_cells() == 0: + orphan_cell = True + print("[ERROR] Found orphan cell '{0}'".format(i.name)) + errors += 1 + if not orphan_cell: + print("[INFO] No orphan cells in the final layout") -if seal_file: - top_cell = top_only_layout.top_cell() + if seal_file: + top_cell = top_only_layout.top_cell() - top_only_layout.read(seal_file) + top_only_layout.read(seal_file) - for cell in top_only_layout.top_cells(): - if cell != top_cell: - print( - "[INFO] Merging '{0}' as child of '{1}'".format( - cell.name, top_cell.name + for cell in top_only_layout.top_cells(): + if cell != top_cell: + print( + "[INFO] Merging '{0}' as child of '{1}'".format( + cell.name, top_cell.name + ) ) + top.insert(pya_mod.CellInstArray(cell.cell_index(), pya_mod.Trans())) + + # Write out the GDS + top_only_layout.write(out_file) + + return errors + + +# When run via klayout -r, globals tech_file, layer_map, in_def, etc. +# are set by klayout's -rd mechanism. +if pya is not None: + try: + # These globals are set by klayout -rd flags + sys.exit( + merge_gds( + pya_mod=pya, + tech_file=tech_file, # noqa: F821 - set by klayout -rd + layer_map=layer_map, # noqa: F821 + in_def=in_def, # noqa: F821 + design_name=design_name, # noqa: F821 + in_files=in_files, # noqa: F821 + seal_file=seal_file, # noqa: F821 + out_file=out_file, # noqa: F821 + allow_empty=os.environ.get("GDS_ALLOW_EMPTY", ""), ) - top.insert(pya.CellInstArray(cell.cell_index(), pya.Trans())) - -# Write out the GDS -top_only_layout.write(out_file) - -sys.exit(errors) + ) + except NameError: + # Not running under klayout -r, pya available but no -rd globals + pass diff --git a/flow/util/generate_klayout_tech.py b/flow/util/generate_klayout_tech.py new file mode 100644 index 0000000000..c054945f71 --- /dev/null +++ b/flow/util/generate_klayout_tech.py @@ -0,0 +1,107 @@ +"""Generate klayout .lyt tech file from platform template. + +Replaces and elements with actual paths. +No klayout dependency - pure XML manipulation using stdlib. +""" + +import argparse +import os +import re + + +def replace_lef_files(content, lef_files): + """Replace the ... element(s) with new ones. + + The original .lyt template has a single element inside + . We replace it with one element + per LEF file, matching the existing sed-based behavior. + """ + replacement = "".join("{}".format(f) for f in lef_files) + return re.sub(r".*?", replacement, content) + + +def replace_map_files(content, map_files): + """Replace the ... or element(s) with new ones.""" + if not map_files: + return content + replacement = "".join("{}".format(f) for f in map_files) + content = re.sub(r".*?", replacement, content) + content = re.sub(r"", replacement, content) + return content + + +def generate_klayout_tech( + template_lyt, + output_lyt, + lef_files, + reference_dir, + map_files, + use_relative_paths, +): + """Generate a klayout .lyt file from a platform template. + + Args: + template_lyt: Path to the platform .lyt template file. + output_lyt: Path to write the generated .lyt file. + lef_files: List of LEF file paths to include. + reference_dir: Directory to compute relative paths from. + map_files: List of map file paths. + use_relative_paths: If True, compute paths relative to reference_dir. + """ + with open(template_lyt, "r") as f: + content = f.read() + + # Both modes use relative paths from reference_dir, matching the + # original sed-based behavior which always uses realpath --relative-to. + resolved_lefs = [ + os.path.relpath(os.path.realpath(f), os.path.realpath(reference_dir)) + for f in lef_files + ] + + content = replace_lef_files(content, resolved_lefs) + + resolved_maps = [os.path.realpath(f) for f in map_files] + content = replace_map_files(content, resolved_maps) + + with open(output_lyt, "w") as f: + f.write(content) + + +def main(): + parser = argparse.ArgumentParser( + description="Generate klayout .lyt tech file from platform template." + ) + parser.add_argument( + "--template", required=True, help="Path to platform .lyt template" + ) + parser.add_argument("--output", required=True, help="Output .lyt file path") + parser.add_argument( + "--lef-files", nargs="*", default=[], help="LEF files to include" + ) + parser.add_argument( + "--reference-dir", + required=True, + help="Directory for computing relative paths", + ) + parser.add_argument( + "--map-files", nargs="*", default=[], help="Map files to include" + ) + parser.add_argument( + "--use-relative-paths", + action="store_true", + help="Use paths relative to reference-dir", + ) + args = parser.parse_args() + + generate_klayout_tech( + template_lyt=args.template, + output_lyt=args.output, + lef_files=args.lef_files, + reference_dir=args.reference_dir, + map_files=args.map_files, + use_relative_paths=args.use_relative_paths, + ) + + +if __name__ == "__main__": + main() diff --git a/flow/util/utils.mk b/flow/util/utils.mk index 3924f57b4e..25c1cfc2ac 100644 --- a/flow/util/utils.mk +++ b/flow/util/utils.mk @@ -157,7 +157,7 @@ $(RESULTS_DIR)/6_final_no_power.def: $(RESULTS_DIR)/6_final.def .PHONY: gallery -gallery: $(RESULTS_DIR)/6_final_no_power.def $(RESULTS_DIR)/6_final_only_clk.def +gallery: check-klayout $(RESULTS_DIR)/6_final_no_power.def $(RESULTS_DIR)/6_final_only_clk.def ($(TIME_CMD) klayout -z -nc -rx -rd gallery_json=util/gallery.json \ -rd results_path=$(RESULTS_DIR) \ -rd tech_file=$(OBJECTS_DIR)/klayout.lyt \