diff --git a/chipcompiler/cli/commands/workspace.py b/chipcompiler/cli/commands/workspace.py index cee92e87..8ac3d55f 100644 --- a/chipcompiler/cli/commands/workspace.py +++ b/chipcompiler/cli/commands/workspace.py @@ -14,6 +14,7 @@ workspace_response, ) from chipcompiler.cli.workspace.service import ( + collect_signoff_package, create_workspace_from_request, get_workspace_home, get_workspace_info, @@ -272,3 +273,24 @@ def get_home_cmd( json_output: Annotated[bool, typer.Option("--json")] = False, ) -> None: _dispatch_runtime(lambda: get_workspace_home(directory), json_output) + + +@workspace_app.command("signoff", help="Collect a harden-flow signoff package") +def signoff_cmd( + directory: Annotated[str, typer.Option("--directory")] = "", + output_dir: Annotated[str, typer.Option("--output")] = "", + archive: Annotated[bool, typer.Option("--archive/--no-archive")] = True, + include_debug: Annotated[bool, typer.Option("--include-debug")] = False, + allow_incomplete: Annotated[bool, typer.Option("--allow-incomplete")] = False, + json_output: Annotated[bool, typer.Option("--json")] = False, +) -> None: + _dispatch_runtime( + lambda: collect_signoff_package( + directory, + output_dir, + archive, + include_debug, + allow_incomplete, + ), + json_output, + ) diff --git a/chipcompiler/cli/workspace/service.py b/chipcompiler/cli/workspace/service.py index d8f2394e..29c70c79 100644 --- a/chipcompiler/cli/workspace/service.py +++ b/chipcompiler/cli/workspace/service.py @@ -204,7 +204,10 @@ def run_workspace_step(directory: str, step: str, rerun: bool) -> dict: def refresh_workspace_config(directory: str) -> dict: cmd = "refresh_config" - response_data = {"directory": os.path.abspath(directory) if directory else "", "refreshed": False} + response_data = { + "directory": os.path.abspath(directory) if directory else "", + "refreshed": False, + } if not directory: return workspace_response( cmd, @@ -394,6 +397,78 @@ def get_workspace_home(directory: str) -> dict: ) +def collect_signoff_package(directory: str, + output_dir: str, + archive: bool, + include_debug: bool, + allow_incomplete: bool) -> dict: + cmd = "signoff" + response_data = { + "directory": os.path.abspath(directory) if directory else "", + "output_dir": os.path.abspath(output_dir) if output_dir else "", + "archive": bool(archive), + "include_debug": bool(include_debug), + "allow_incomplete": bool(allow_incomplete), + } + if not directory: + return workspace_response( + cmd, + "failed", + data=response_data, + message=["missing required field: directory"], + ) + + try: + workspace, engine_flow = load_workspace_runtime( + directory, + create_step_workspaces=False, + ) + from chipcompiler.engine.signoff import SignoffPackageOptions + + result = engine_flow.collect_signoff_package( + SignoffPackageOptions( + output_dir=output_dir or None, + archive=archive, + include_debug=include_debug, + allow_incomplete=allow_incomplete, + ) + ) + except WorkspaceValidationError as exc: + return workspace_response(cmd, "failed", data=response_data, message=[str(exc)]) + except Exception as exc: + return workspace_response( + cmd, + "error", + data=response_data, + message=[f"collect signoff package failed : {exc}"], + ) + + response_data.update({ + "directory": os.path.abspath(workspace.directory), + "package_dir": result.package_dir, + "archive_path": result.archive_path or "", + "manifest_path": result.manifest_path or "", + "summary_path": result.summary_path or "", + "copied_count": len(result.copied), + "missing_required": result.missing_required, + "warnings": result.warnings, + }) + if result.ok: + return workspace_response( + cmd, + "success", + data=response_data, + message=[f"collect signoff package success : {result.package_dir}"], + ) + + return workspace_response( + cmd, + "failed", + data=response_data, + message=["collect signoff package incomplete"], + ) + + def load_workspace_runtime(directory: str, create_step_workspaces: bool = True): import chipcompiler.data as data_api diff --git a/chipcompiler/engine/__init__.py b/chipcompiler/engine/__init__.py index b6f0a2de..0b2be020 100644 --- a/chipcompiler/engine/__init__.py +++ b/chipcompiler/engine/__init__.py @@ -6,7 +6,14 @@ EngineFlow ) +from .signoff import ( + SignoffPackageCollector, + SignoffPackageOptions +) + __all__ = [ 'EngineDB', - 'EngineFlow' + 'EngineFlow', + 'SignoffPackageCollector', + 'SignoffPackageOptions' ] \ No newline at end of file diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index c5ed908f..924b1f17 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -1,18 +1,24 @@ #!/usr/bin/env python # -*- encoding: utf-8 -*- +import logging import os import time -import logging import traceback from threading import Event, Thread -from chipcompiler.data import Workspace, WorkspaceStep, StateEnum, StepEnum, log_flow +from chipcompiler.data import StateEnum, StepEnum, Workspace, WorkspaceStep, log_flow from chipcompiler.engine import EngineDB +from chipcompiler.engine.signoff import ( + SignoffPackageCollector, + SignoffPackageOptions, + SignoffPackageResult, +) from chipcompiler.utility.log import redirect_stdio_to_file logger = logging.getLogger(__name__) + def get_process_rss_mb(pid : int) -> float: peak_memory = 0 try: @@ -220,6 +226,15 @@ def check_step_result(self, success = True return success + def collect_signoff_package( + self, + options: SignoffPackageOptions | None = None, + ) -> SignoffPackageResult: + """ + Collect harden-flow signoff resources from this flow workspace. + """ + return SignoffPackageCollector(self.workspace).collect(options) + def create_step_workspaces(self): """ create all step workspaces diff --git a/chipcompiler/engine/signoff.py b/chipcompiler/engine/signoff.py new file mode 100644 index 00000000..7cf7d564 --- /dev/null +++ b/chipcompiler/engine/signoff.py @@ -0,0 +1,614 @@ +import glob +import hashlib +import json +import shutil +import tarfile +import time +from dataclasses import dataclass, field +from pathlib import Path + +from chipcompiler.data import StateEnum, StepEnum, Workspace + + +@dataclass(frozen=True) +class SignoffPackageOptions: + output_dir: str | None = None + archive: bool = True + include_debug: bool = False + allow_incomplete: bool = False + + +@dataclass +class SignoffPackageResult: + ok: bool + package_dir: str + archive_path: str | None = None + manifest_path: str | None = None + summary_path: str | None = None + copied: list[dict] = field(default_factory=list) + missing_required: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + +class SignoffPackageCollector: + def __init__(self, workspace: Workspace): + self.workspace = workspace + + def collect( + self, + options: SignoffPackageOptions | None = None, + ) -> SignoffPackageResult: + options = options or SignoffPackageOptions() + if self.workspace is None or not self.workspace.directory: + raise FileNotFoundError("workspace is not configured") + + workspace_dir = Path(self.workspace.directory) + if not workspace_dir.exists(): + raise FileNotFoundError(f"workspace does not exist: {workspace_dir}") + + parameters = self._read_json(workspace_dir / "home" / "parameters.json") + design = ( + self.workspace.design.name + or parameters.get("Design", "") + or self._design_from_outputs(workspace_dir) + ) + top_module = self.workspace.design.top_module or parameters.get("Top module", "") or design + pdk_name = getattr(self.workspace.pdk, "name", "") or parameters.get("PDK", "") + if not design: + raise ValueError("cannot determine design name for signoff package") + + package_root = Path(options.output_dir) if options.output_dir else workspace_dir / "signoff" + package_dir = package_root / f"{design}_signoff_package" + if package_dir.exists(): + shutil.rmtree(package_dir) + package_dir.mkdir(parents=True, exist_ok=True) + + copied: list[dict] = [] + missing_required: list[str] = [] + warnings: list[str] = [] + + def add_file(role: str, + source: Path | None, + destination: str, + required: bool = False) -> None: + self._add_file( + workspace_dir=workspace_dir, + package_dir=package_dir, + role=role, + source=source, + destination=destination, + required=required, + copied=copied, + missing_required=missing_required, + ) + + flow_path = workspace_dir / "home" / "flow.json" + checklist_path = workspace_dir / "home" / "checklist.json" + flow_data = self.workspace.flow.data or self._read_json(flow_path) + checklist_data = self._read_json(checklist_path) + + required_steps = self._required_step_states(flow_data) + for step_name, state in required_steps.items(): + if state != StateEnum.Success.value: + missing_required.append(f"flow step {step_name} is {state or 'missing'}") + + config_dir = workspace_dir / "config" + required_configs = { + "db_default_config.json", + "flow_config.json", + "rcx.json", + "sta.json", + } + if not config_dir.is_dir(): + missing_required.append("config directory") + else: + for config_file in sorted(path for path in config_dir.rglob("*") if path.is_file()): + rel = config_file.relative_to(config_dir).as_posix() + add_file( + role=f"config.{config_file.stem}", + source=config_file, + destination=f"config/{rel}", + required=config_file.name in required_configs, + ) + for config_name in sorted(required_configs): + if not (config_dir / config_name).is_file(): + missing_required.append(f"config/{config_name}") + + db_config = self._read_json(config_dir / "db_default_config.json") + origin_verilog = self._find_one( + workspace_dir / "origin", + preferred_name=f"{design}.v", + pattern="*.v", + missing_label="origin Verilog", + missing_required=missing_required, + ) + origin_sdc = self._path_from_config( + workspace_dir, + db_config.get("INPUT", {}).get("sdc_path", ""), + ) + if origin_sdc is None: + origin_sdc = self._find_one( + workspace_dir / "origin", + preferred_name=f"{design}.sdc", + pattern="*.sdc", + missing_label="origin SDC", + missing_required=missing_required, + ) + + add_file( + role="harden.gds", + source=workspace_dir / "Harden_ecc" / "output" / f"{design}_Harden.gds", + destination=f"harden/{design}.gds", + required=True, + ) + add_file( + role="harden.lef", + source=workspace_dir / "Harden_ecc" / "output" / f"{design}_Harden.lef", + destination=f"harden/{design}.lef", + required=True, + ) + add_file( + role="harden.lib", + source=workspace_dir / "Harden_ecc" / "output" / f"{design}_Harden.lib", + destination=f"harden/{design}.lib", + required=True, + ) + add_file( + role="harden.lib_check_sources", + source=( + workspace_dir + / "Harden_ecc" + / "output" + / f"{design}_Harden.lib.check_sources.tsv" + ), + destination=f"harden/{design}.lib.check_sources.tsv", + ) + add_file( + role="harden.image", + source=workspace_dir / "Harden_ecc" / "output" / f"{design}_Harden.png", + destination=f"harden/{design}.png", + ) + + add_file("initial.verilog", origin_verilog, f"initial/{design}.v", required=True) + add_file("initial.sdc", origin_sdc, f"initial/{design}.sdc", required=True) + add_file( + "initial.parameters", + workspace_dir / "home" / "parameters.json", + "initial/parameters.json", + required=True, + ) + + add_file( + role="final.design.verilog", + source=workspace_dir / "filler_ecc" / "output" / f"{design}_filler.v.gz", + destination=f"final/design/{design}.v.gz", + required=True, + ) + add_file( + role="final.design.def", + source=workspace_dir / "filler_ecc" / "output" / f"{design}_filler.def.gz", + destination=f"final/design/{design}.def.gz", + required=True, + ) + add_file( + role="final.design.gds", + source=workspace_dir / "filler_ecc" / "output" / f"{design}_filler.gds", + destination=f"final/design/{design}.gds", + required=True, + ) + add_file( + role="final.design.image", + source=workspace_dir / "filler_ecc" / "output" / f"{design}_filler.png", + destination=f"final/design/{design}.png", + ) + + sta_config = self._read_json(config_dir / "sta.json") + sta_matrix = self._sta_matrix(sta_config) + expected_spefs = set() + for item in sta_matrix: + expected_spefs.add( + f"{design}_{item['rcx_corner']}_{self._temperature_token(item['temperature'])}C.spef" + ) + report_dir = ( + workspace_dir + / "sta_ecc" + / "output" + / f"{item['lib_corner']}_{self._temperature_token(item['temperature'])}" + / item["rcx_corner"] + ) + report_dest = ( + f"final/timing/sta/{item['lib_corner']}_" + f"{self._temperature_token(item['temperature'])}/" + f"{item['rcx_corner']}" + ) + for suffix in ( + ".rpt.json", + ".rpt", + ".cap", + ".fanout", + ".trans", + "_hold.skew", + "_setup.skew", + ): + add_file( + role="final.sta_report", + source=report_dir / f"{top_module}{suffix}", + destination=f"{report_dest}/{top_module}{suffix}", + required=suffix in (".rpt.json", ".rpt"), + ) + item["report"] = f"{report_dest}/{top_module}.rpt.json" + + rcx_output_dir = workspace_dir / "RCX_ecc" / "output" + spef_paths = sorted(rcx_output_dir.glob("*.spef")) if rcx_output_dir.is_dir() else [] + if expected_spefs: + for spef_name in sorted(expected_spefs): + add_file( + role="final.spef", + source=rcx_output_dir / spef_name, + destination=f"final/timing/spef/{spef_name}", + required=True, + ) + for spef_path in spef_paths: + if spef_path.name not in expected_spefs: + add_file( + role="final.spef", + source=spef_path, + destination=f"final/timing/spef/{spef_path.name}", + ) + elif spef_paths: + for spef_path in spef_paths: + add_file( + role="final.spef", + source=spef_path, + destination=f"final/timing/spef/{spef_path.name}", + required=True, + ) + else: + missing_required.append("RCX SPEF files") + + add_file("status.flow", flow_path, "final/reports/flow.json", required=True) + add_file( + "status.checklist", + checklist_path, + "final/reports/checklist.json", + required=True, + ) + + for step_name, step_dir in self._step_dirs().items(): + for kind in ("analysis", "report"): + self._copy_tree_files( + workspace_dir=workspace_dir, + package_dir=package_dir, + source_dir=workspace_dir / step_dir / kind, + destination_dir=f"final/reports/{step_name}/{kind}", + role=f"report.{kind}", + copied=copied, + ) + + if options.include_debug: + self._collect_debug_files( + workspace_dir=workspace_dir, + package_dir=package_dir, + copied=copied, + ) + + checklist_counts = self._checklist_counts(checklist_data) + if checklist_counts.get("warning", 0) or checklist_counts.get("failed", 0): + warnings.append( + "home checklist contains failed or warning items; " + "see final/reports/checklist.json" + ) + + metrics = self._read_json( + workspace_dir / "drc_ecc" / "analysis" / "drc_metrics.json" + ) + ok = len(missing_required) == 0 + flow_success = all( + state == StateEnum.Success.value + for state in required_steps.values() + ) + summary = { + "schema_version": 1, + "status": "ok" if ok else "incomplete", + "design": design, + "top_module": top_module, + "pdk": pdk_name, + "required_steps": required_steps, + "checks": { + "flow": "passed" if flow_success else "failed", + "home_checklist": checklist_counts, + }, + "initial": { + "verilog": f"initial/{design}.v", + "sdc": f"initial/{design}.sdc", + "parameters": "initial/parameters.json", + }, + "config": "config/", + "harden": { + "gds": f"harden/{design}.gds", + "lef": f"harden/{design}.lef", + "lib": f"harden/{design}.lib", + }, + "final": { + "verilog": f"final/design/{design}.v.gz", + "def": f"final/design/{design}.def.gz", + "gds": f"final/design/{design}.gds", + "image": f"final/design/{design}.png", + }, + "metrics": metrics, + "sta_matrix": sta_matrix, + "missing_required": missing_required, + "warnings": warnings, + } + summary_path = package_dir / "summary.json" + summary_path.write_text(json.dumps(summary, indent=2)) + + manifest = { + "schema_version": 1, + "created_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "workspace": str(workspace_dir.resolve()), + "design": design, + "top_module": top_module, + "pdk": pdk_name, + "flow": { + "source": "home/flow.json", + "all_required_steps_success": flow_success, + }, + "files": copied, + "missing_required": missing_required, + "warnings": warnings, + } + manifest_path = package_dir / "manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2)) + + readme_path = package_dir / "README.md" + readme_path.write_text( + f"# {design} Signoff Package\n\n" + f"- Workspace: {workspace_dir.resolve()}\n" + f"- Status: {summary['status']}\n" + "- Harden outputs are under `harden/`.\n" + "- Final physical resources are under `final/`.\n" + ) + + archive_path = None + if options.archive and (ok or options.allow_incomplete): + archive_path = str(package_dir.with_suffix(".tar.gz")) + archive_file = Path(archive_path) + if archive_file.exists(): + archive_file.unlink() + with tarfile.open(archive_file, "w:gz") as archive: + archive.add(package_dir, arcname=package_dir.name) + + return SignoffPackageResult( + ok=ok, + package_dir=str(package_dir), + archive_path=archive_path, + manifest_path=str(manifest_path), + summary_path=str(summary_path), + copied=copied, + missing_required=missing_required, + warnings=warnings, + ) + + def _add_file(self, + workspace_dir: Path, + package_dir: Path, + role: str, + source: Path | None, + destination: str, + required: bool, + copied: list[dict], + missing_required: list[str]) -> None: + if source is None or not source.is_file() or source.stat().st_size <= 0: + if required: + missing_required.append(destination) + return + + target = package_dir / destination + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + copied.append({ + "role": role, + "required": required, + "source": self._source_path(workspace_dir, source), + "destination": destination, + "size_bytes": target.stat().st_size, + "sha256": self._sha256(target), + }) + + def _copy_tree_files(self, + workspace_dir: Path, + package_dir: Path, + source_dir: Path, + destination_dir: str, + role: str, + copied: list[dict]) -> None: + if not source_dir.is_dir(): + return + for source in sorted(path for path in source_dir.rglob("*") if path.is_file()): + relative = source.relative_to(source_dir).as_posix() + self._add_file( + workspace_dir=workspace_dir, + package_dir=package_dir, + role=role, + source=source, + destination=f"{destination_dir}/{relative}", + required=False, + copied=copied, + missing_required=[], + ) + + def _collect_debug_files(self, + workspace_dir: Path, + package_dir: Path, + copied: list[dict]) -> None: + patterns = [ + "*_ecc/feature/*", + "*_ecc/subflow.json", + "sta_ecc/output/**/wire_paths/*", + ] + for pattern in patterns: + for path_text in sorted(glob.glob(str(workspace_dir / pattern), recursive=True)): + source = Path(path_text) + if not source.is_file(): + continue + destination = f"debug/{source.relative_to(workspace_dir).as_posix()}" + self._add_file( + workspace_dir=workspace_dir, + package_dir=package_dir, + role="debug", + source=source, + destination=destination, + required=False, + copied=copied, + missing_required=[], + ) + output_db_dirs = sorted(workspace_dir.glob("*_ecc/output/*_db")) + output_view_dirs = sorted(workspace_dir.glob("*_ecc/output/*_view")) + for source in output_db_dirs + output_view_dirs: + if not source.is_dir(): + continue + self._copy_tree_files( + workspace_dir=workspace_dir, + package_dir=package_dir, + source_dir=source, + destination_dir=f"debug/{source.relative_to(workspace_dir).as_posix()}", + role="debug", + copied=copied, + ) + + def _read_json(self, path: Path) -> dict: + try: + with open(path, encoding="utf-8") as file: + data = json.load(file) + except (OSError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + def _sha256(self, path: Path) -> str: + digest = hashlib.sha256() + with open(path, "rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + def _path_from_config(self, + workspace_dir: Path, + path_text: str) -> Path | None: + if not path_text: + return None + path = Path(path_text) + if not path.is_absolute(): + path = workspace_dir / path + return path if path.is_file() else None + + def _source_path(self, workspace_dir: Path, source: Path) -> str: + try: + return source.relative_to(workspace_dir).as_posix() + except ValueError: + return str(source) + + def _find_one(self, + directory: Path, + preferred_name: str, + pattern: str, + missing_label: str, + missing_required: list[str]) -> Path | None: + preferred = directory / preferred_name + if preferred.is_file(): + return preferred + matches = sorted(directory.glob(pattern)) if directory.is_dir() else [] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + missing_required.append(f"{missing_label}: multiple matches") + return None + missing_required.append(missing_label) + return None + + def _design_from_outputs(self, workspace_dir: Path) -> str: + for pattern, suffix in ( + ("Harden_ecc/output/*_Harden.gds", "_Harden.gds"), + ("filler_ecc/output/*_filler.v.gz", "_filler.v.gz"), + ): + matches = sorted(workspace_dir.glob(pattern)) + if matches: + name = matches[0].name + if name.endswith(suffix): + return name[: -len(suffix)] + return "" + + def _required_step_states(self, flow_data: dict) -> dict: + required = [ + StepEnum.HARDEN.value, + StepEnum.RCX.value, + StepEnum.STA.value, + StepEnum.DRC.value, + StepEnum.FILLER.value, + StepEnum.ROUTING.value, + ] + state_by_step = { + step.get("name"): step.get("state", "") + for step in flow_data.get("steps", []) + if isinstance(step, dict) + } + return {step: state_by_step.get(step, "") for step in required} + + def _checklist_counts(self, checklist_data: dict) -> dict: + counts = {"passed": 0, "warning": 0, "failed": 0} + for item in checklist_data.get("checklist", []): + state = str(item.get("state", "")).lower() + if state == "passed": + counts["passed"] += 1 + elif state == "warning": + counts["warning"] += 1 + elif state == "failed": + counts["failed"] += 1 + return counts + + def _sta_matrix(self, sta_config: dict) -> list[dict]: + liberty_by_corner = { + item.get("corner"): item + for item in sta_config.get("liberty", []) + if isinstance(item, dict) + } + matrix = [] + for signoff_group in sta_config.get("signoff", []): + if not isinstance(signoff_group, dict): + continue + for lib_corner, rcx_corners in signoff_group.items(): + liberty = liberty_by_corner.get(lib_corner, {}) + if isinstance(rcx_corners, str): + rcx_corners = [rcx_corners] + for rcx_corner in rcx_corners: + matrix.append({ + "lib_corner": lib_corner, + "temperature": liberty.get("temperature", ""), + "rcx_corner": rcx_corner, + }) + return matrix + + def _temperature_token(self, temperature) -> str: + try: + numeric = float(temperature) + if numeric.is_integer(): + temperature = int(numeric) + except (TypeError, ValueError): + pass + return str(temperature).replace("-", "m").replace(".", "p") + + def _step_dirs(self) -> dict[str, str]: + return { + StepEnum.SYNTHESIS.value: "Synthesis_yosys", + StepEnum.FLOORPLAN.value: "Floorplan_ecc", + StepEnum.NETLIST_OPT.value: "fixFanout_ecc", + StepEnum.PLACEMENT.value: "place_dreamplace", + StepEnum.CTS.value: "CTS_ecc", + StepEnum.LEGALIZATION.value: "legalization_dreamplace", + StepEnum.ROUTING.value: "route_ecc", + StepEnum.DRC.value: "drc_ecc", + StepEnum.FILLER.value: "filler_ecc", + StepEnum.RCX.value: "RCX_ecc", + StepEnum.STA.value: "sta_ecc", + StepEnum.HARDEN.value: "Harden_ecc", + } diff --git a/chipcompiler/thirdparty/ecc-tools b/chipcompiler/thirdparty/ecc-tools index 2e3928a7..45b4f883 160000 --- a/chipcompiler/thirdparty/ecc-tools +++ b/chipcompiler/thirdparty/ecc-tools @@ -1 +1 @@ -Subproject commit 2e3928a78f8bca971324beba70bac33945904f87 +Subproject commit 45b4f88338d631f6b0b34ce2d8dd4a991a9bdefe diff --git a/chipcompiler/tools/ecc/module.py b/chipcompiler/tools/ecc/module.py index a351f33a..5eaee77f 100644 --- a/chipcompiler/tools/ecc/module.py +++ b/chipcompiler/tools/ecc/module.py @@ -224,11 +224,39 @@ def json_save(self, path : str): self.ecc.json_save(path=path) - def view_json_save(self, output_dir: str): - return self.ecc.view_json_save(output_dir=output_dir) + def view_json_save( + self, + output_dir: str, + json_format: str = "pretty", + compress: bool = False, + ): + """ + Export the current iDB design as a view JSON package. - def view_json_apply_edits(self, edits_path: str): - return self.ecc.view_json_apply_edits(edits_path=edits_path) + Args: + output_dir: Directory used to write manifest.json and package files. + json_format: JSON text layout. Use "pretty" for indented output or + "compact" to remove extra spaces/newlines and reduce file size. + compress: When True, write package JSON files as .json.gz. The + manifest.json entry file remains plain JSON and points to the + compressed package files. + """ + return self.ecc.view_json_save( + output_dir=output_dir, + json_format=json_format, + compress=compress, + ) + + def view_json_apply_edits(self, edits_path: str, compress: bool = False): + """ + Apply edits generated for a view JSON package. + + Args: + edits_path: Path to layout_edits.json or layout_edits.json.gz. + compress: When True, prefer reading edits_path + ".gz" if edits_path + does not already end with ".gz". + """ + return self.ecc.view_json_apply_edits(edits_path=edits_path, compress=compress) def save_data(self, path: str): """save ECC data""" diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index efa0bf23..5fde8d79 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -142,8 +142,11 @@ def save_data(workspace: Workspace, ecc_module.gds_save(output_path=step.output.get("gds", "")) ecc_module.save_data(path=step.output.get("db", "")) # ecc_module.json_save(path=step.output.get("json", "")) - ecc_module.view_json_save(output_dir=step.output.get("view_json", "")) - ecc_module.view_json_apply_edits(edits_path=step.output.get("view_json_edits", "")) + ecc_module.view_json_save(output_dir=step.output.get("view_json", ""), + json_format="compact", + compress=True) + ecc_module.view_json_apply_edits(edits_path=step.output.get("view_json_edits", ""), + compress=True) ecc_module.feature_sammry(json_path=step.feature.get("db", "")) if feature_step: ecc_module.feature_step(step=step.name, diff --git a/test/cli/workspace/test_workspace_cli.py b/test/cli/workspace/test_workspace_cli.py index 9e71ab45..ce9045fe 100644 --- a/test/cli/workspace/test_workspace_cli.py +++ b/test/cli/workspace/test_workspace_cli.py @@ -1174,6 +1174,7 @@ def test_workspace_help_uses_typer_app(capsys): assert "Usage: ecc workspace" in out assert "create" in out assert "run-flow" in out + assert "signoff" in out def test_workspace_create_help_lists_existing_options(capsys): @@ -1191,6 +1192,66 @@ def test_workspace_create_help_lists_existing_options(capsys): assert "--freq" in out +def test_workspace_signoff_routes_to_service(monkeypatch, tmp_path, capsys): + calls = {} + + def fake_collect_signoff_package( + directory, + output_dir, + archive, + include_debug, + allow_incomplete, + ): + calls.update({ + "directory": directory, + "output_dir": output_dir, + "archive": archive, + "include_debug": include_debug, + "allow_incomplete": allow_incomplete, + }) + return { + "cmd": "signoff", + "response": "success", + "data": { + "package_dir": str(tmp_path / "out" / "gcd_signoff_package"), + "archive_path": "", + "copied_count": 3, + }, + "message": ["ok"], + } + + monkeypatch.setattr( + "chipcompiler.cli.commands.workspace.collect_signoff_package", + fake_collect_signoff_package, + ) + + rc = cli_main.run([ + "workspace", + "signoff", + "--directory", + str(tmp_path / "workspace"), + "--output", + str(tmp_path / "out"), + "--no-archive", + "--include-debug", + "--allow-incomplete", + "--json", + ]) + + result = _response(capsys) + assert rc == 0 + assert result["cmd"] == "signoff" + assert result["response"] == "success" + assert result["data"]["copied_count"] == 3 + assert calls == { + "directory": str(tmp_path / "workspace"), + "output_dir": str(tmp_path / "out"), + "archive": False, + "include_debug": True, + "allow_incomplete": True, + } + + def test_workspace_json_output_suppresses_runtime_stdout(monkeypatch, tmp_path, capsys): from chipcompiler.cli.workspace.response import workspace_response diff --git a/test/test_signoff_package.py b/test/test_signoff_package.py new file mode 100644 index 00000000..a4bdaeff --- /dev/null +++ b/test/test_signoff_package.py @@ -0,0 +1,109 @@ +import json +from pathlib import Path + +from chipcompiler.data import OriginDesign, Parameters, StateEnum, Workspace +from chipcompiler.engine import EngineFlow +from chipcompiler.engine.signoff import SignoffPackageOptions + + +def _write(path: Path, text: str = "data") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + +def _write_json(path: Path, data: dict) -> None: + _write(path, json.dumps(data, indent=2)) + + +def _make_signoff_workspace(tmp_path: Path) -> Path: + workspace_dir = tmp_path / "gcd_workspace" + design = "gcd" + + _write(workspace_dir / "origin" / f"{design}.v", "module gcd; endmodule\n") + _write(workspace_dir / "origin" / f"{design}.sdc", "create_clock -period 10 clk\n") + _write_json( + workspace_dir / "home" / "parameters.json", + {"Design": design, "Top module": design, "PDK": "ics55"}, + ) + _write_json( + workspace_dir / "home" / "flow.json", + { + "steps": [ + {"name": "route", "tool": "ecc", "state": StateEnum.Success.value}, + {"name": "drc", "tool": "ecc", "state": StateEnum.Success.value}, + {"name": "filler", "tool": "ecc", "state": StateEnum.Success.value}, + {"name": "RCX", "tool": "ecc", "state": StateEnum.Success.value}, + {"name": "sta", "tool": "ecc", "state": StateEnum.Success.value}, + {"name": "Harden", "tool": "ecc", "state": StateEnum.Success.value}, + ], + }, + ) + _write_json(workspace_dir / "home" / "checklist.json", {"checklist": []}) + + _write_json( + workspace_dir / "config" / "sta.json", + { + "liberty": [{"corner": "MAX", "temperature": 125, "path": ["max.lib"]}], + "signoff": [{"MAX": ["RCworst"]}], + }, + ) + for config_name in ("db_default_config.json", "flow_config.json", "rcx.json"): + _write_json(workspace_dir / "config" / config_name, {}) + + _write(workspace_dir / "Harden_ecc" / "output" / f"{design}_Harden.gds") + _write(workspace_dir / "Harden_ecc" / "output" / f"{design}_Harden.lef") + _write(workspace_dir / "Harden_ecc" / "output" / f"{design}_Harden.lib") + _write(workspace_dir / "filler_ecc" / "output" / f"{design}_filler.v.gz") + _write(workspace_dir / "filler_ecc" / "output" / f"{design}_filler.def.gz") + _write(workspace_dir / "filler_ecc" / "output" / f"{design}_filler.gds") + _write(workspace_dir / "filler_ecc" / "output" / f"{design}_filler.png") + _write(workspace_dir / "RCX_ecc" / "output" / f"{design}_RCworst_125C.spef") + + sta_dir = workspace_dir / "sta_ecc" / "output" / "MAX_125" / "RCworst" + _write_json(sta_dir / f"{design}.rpt.json", {"slack": []}) + _write(sta_dir / f"{design}.rpt") + + _write_json(workspace_dir / "route_ecc" / "analysis" / "route_metrics.json", {}) + _write(workspace_dir / "route_ecc" / "report" / "route.db.rpt") + return workspace_dir + + +def _make_engine_flow(workspace_dir: Path) -> EngineFlow: + workspace = Workspace() + workspace.directory = str(workspace_dir) + workspace.design = OriginDesign(name="gcd", top_module="gcd") + workspace.flow.path = str(workspace_dir / "home" / "flow.json") + workspace.parameters = Parameters( + path=str(workspace_dir / "home" / "parameters.json"), + data={"Design": "gcd", "Top module": "gcd", "PDK": "ics55"}, + ) + return EngineFlow(workspace=workspace) + + +def test_collect_signoff_package_uses_final_design_layout(tmp_path): + workspace_dir = _make_signoff_workspace(tmp_path) + engine_flow = _make_engine_flow(workspace_dir) + + result = engine_flow.collect_signoff_package(SignoffPackageOptions(archive=True)) + + package_dir = Path(result.package_dir) + assert result.ok is True + assert (package_dir / "final" / "design" / "gcd.v.gz").is_file() + assert (package_dir / "final" / "design" / "gcd.def.gz").is_file() + assert (package_dir / "final" / "design" / "gcd.gds").is_file() + assert (package_dir / "final" / "design" / "gcd.png").is_file() + assert (package_dir / "final" / "timing" / "spef" / "gcd_RCworst_125C.spef").is_file() + assert (package_dir / "final" / "reports" / "flow.json").is_file() + assert not (package_dir / "signoff").exists() + assert not (package_dir / "final" / "final").exists() + + summary = json.loads((package_dir / "summary.json").read_text()) + assert summary["final"]["verilog"] == "final/design/gcd.v.gz" + assert summary["sta_matrix"][0]["report"] == ( + "final/timing/sta/MAX_125/RCworst/gcd.rpt.json" + ) + + manifest = json.loads((package_dir / "manifest.json").read_text()) + destinations = {item["destination"] for item in manifest["files"]} + assert "final/design/gcd.def.gz" in destinations + assert "final/reports/route/analysis/route_metrics.json" in destinations diff --git a/test/tools/ecc/test_module.py b/test/tools/ecc/test_module.py index b6f1f706..30f2b8e4 100644 --- a/test/tools/ecc/test_module.py +++ b/test/tools/ecc/test_module.py @@ -54,25 +54,25 @@ def test_init_rcx_omits_explicit_empty_pdk_for_backward_compatibility(): assert module.ecc.calls == [{"config": "/tmp/rcx.json"}] -def test_view_json_save_passes_output_dir(): +def test_view_json_save_passes_output_options(): module = ECCToolsModule.__new__(ECCToolsModule) module.ecc = FakeEcc() - assert module.view_json_save(output_dir="/tmp/view_json") is True + assert module.view_json_save(output_dir="/tmp/view_json", json_format="compact", compress=True) is True assert module.ecc.calls == [ - ("view_json_save", {"output_dir": "/tmp/view_json"}), + ("view_json_save", {"output_dir": "/tmp/view_json", "json_format": "compact", "compress": True}), ] -def test_view_json_apply_edits_passes_edits_path(): +def test_view_json_apply_edits_passes_compress_option(): module = ECCToolsModule.__new__(ECCToolsModule) module.ecc = FakeEcc() - assert module.view_json_apply_edits(edits_path="/tmp/view_json/edits/layout_edits.json") is True + assert module.view_json_apply_edits(edits_path="/tmp/view_json/edits/layout_edits.json.gz", compress=True) is True assert module.ecc.calls == [ - ("view_json_apply_edits", {"edits_path": "/tmp/view_json/edits/layout_edits.json"}), + ("view_json_apply_edits", {"edits_path": "/tmp/view_json/edits/layout_edits.json.gz", "compress": True}), ]