diff --git a/chipfoundry_cli/__init__.py b/chipfoundry_cli/__init__.py index a11258f..9dfd181 100644 --- a/chipfoundry_cli/__init__.py +++ b/chipfoundry_cli/__init__.py @@ -1,2 +1,2 @@ """ChipFoundry CLI package: Automate project submission to SFTP.""" -__version__ = "2.7.1" \ No newline at end of file +__version__ = "2.7.2" \ No newline at end of file diff --git a/chipfoundry_cli/preview.py b/chipfoundry_cli/preview.py index 6b9837a..a2400cd 100644 --- a/chipfoundry_cli/preview.py +++ b/chipfoundry_cli/preview.py @@ -41,6 +41,9 @@ LINT_FAILED = "failed" LINT_SKIPPED = "skipped" +AREA_SOURCE_DIE = "die_area" +AREA_SOURCE_SYNTHESIS = "synthesis" + _ELABORATE_ONLY_STRATEGIES = { STRATEGY_HIER_MACRO_FIRST, STRATEGY_HIER_MACRO_FIRST_LEGACY, @@ -105,6 +108,30 @@ def die_area_mm2(config: Dict[str, Any]) -> Optional[Decimal]: return (Decimal(str(um2)) / Decimal("1000000")).quantize(Decimal("0.0001")) +def floorplan_area_is_fixed(config: Dict[str, Any]) -> bool: + """True when DIE_AREA is the macro footprint LibreLane will use. + + ``FP_SIZING=relative`` sizes the die from synthesized cells. Absolute + sizing, and a DIE_AREA with no FP_SIZING, is a fixed footprint. + """ + sizing = config.get("FP_SIZING") + if isinstance(sizing, str) and sizing.strip().lower() == "relative": + return False + return die_area_mm2(config) is not None + + +def _apply_die_area(row: Dict[str, Any], config: Dict[str, Any]) -> None: + """Record DIE_AREA. A fixed footprint is not replaced by synthesis later.""" + area = die_area_mm2(config) + if area is None: + return + if floorplan_area_is_fixed(config): + row["estimated_area_mm2"] = str(area) + row["area_source"] = AREA_SOURCE_DIE + elif row.get("estimated_area_mm2") is None: + row["estimated_area_mm2"] = str(area) + + def _stem_from_view_path(value: Any) -> Optional[str]: if not isinstance(value, str) or not value.strip(): return None @@ -262,6 +289,7 @@ def scan_project(project_root: Path) -> Dict[str, Any]: "source": "cli_preview", "config_path": row["config_path"], "estimated_area_mm2": None, + "area_source": None, "gate_count": None, "instances": [], "integration_strategy": None, @@ -283,7 +311,7 @@ def scan_project(project_root: Path) -> Dict[str, Any]: else: parent = design_name.strip() - area = die_area_mm2(config) + is_new = parent not in macros_by_name parent_row = macros_by_name.setdefault( parent, { @@ -292,19 +320,23 @@ def scan_project(project_root: Path) -> Dict[str, Any]: "is_top_level": False, "source": "cli_preview", "config_path": row["config_path"], - "estimated_area_mm2": str(area) if area is not None else None, + "estimated_area_mm2": None, + "area_source": None, "gate_count": None, "instances": [], "integration_strategy": None, "lint_status": None, }, ) - if parent_row["config_path"] != row["config_path"]: + if is_new or not parent_row.get("config_path") or parent_row["config_path"] == row["config_path"]: + if not is_new and not parent_row.get("config_path"): + parent_row["config_path"] = row["config_path"] + parent_row["notes"] = f"from {row['config_path']}" + _apply_die_area(parent_row, config) + else: notes.append( f"Multiple configs declare DESIGN_NAME {parent!r}; using {parent_row['config_path']}" ) - elif area is not None and parent_row["estimated_area_mm2"] is None: - parent_row["estimated_area_mm2"] = str(area) macro_section = config.get("MACROS") if isinstance(macro_section, dict): @@ -330,6 +362,7 @@ def scan_project(project_root: Path) -> Dict[str, Any]: "source": "cli_preview", "config_path": None, "estimated_area_mm2": None, + "area_source": None, "gate_count": None, "instances": [], "integration_strategy": STRATEGY_HARD_IP, @@ -362,6 +395,7 @@ def scan_project(project_root: Path) -> Dict[str, Any]: "source": "cli_preview", "config_path": None, "estimated_area_mm2": None, + "area_source": None, "gate_count": None, "instances": [], "integration_strategy": STRATEGY_HARD_IP, @@ -471,12 +505,13 @@ def apply_synth_metrics( macro["lint_status"] = LINT_FAILED if strategy_uses_gate_count(strategy): macro["gate_count"] = metrics["gate_count"] - area = metrics.get("estimated_area_mm2") - if area is not None: - macro["estimated_area_mm2"] = str(area) - elif macro.get("estimated_area_mm2") is None and metrics.get("estimated_area_mm2") is not None: - # Keep DIE_AREA / catalog area; do not treat Yosys cells as gates. - pass + # Cell area is the sum of synthesized gates. A fixed DIE_AREA is the + # footprint OpenLane will actually build. + if macro.get("area_source") != AREA_SOURCE_DIE: + area = metrics.get("estimated_area_mm2") + if area is not None: + macro["estimated_area_mm2"] = str(area) + macro["area_source"] = AREA_SOURCE_SYNTHESIS def leaf_summary(macros: Sequence[Dict[str, Any]]) -> Dict[str, Any]: @@ -675,6 +710,7 @@ def build_preview_payload( "source": "cli_preview", "config_path": row.get("config_path"), "estimated_area_mm2": row.get("estimated_area_mm2"), + "area_source": row.get("area_source"), "gate_count": row.get("gate_count"), "instances": row.get("instances") or [], "integration_strategy": row.get("integration_strategy"), diff --git a/pyproject.toml b/pyproject.toml index 12ba02f..f27aec2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chipfoundry-cli" -version = "2.7.1" +version = "2.7.2" description = "CLI tool to automate ChipFoundry project submission to SFTP server" authors = ["ChipFoundry "] readme = "README.md" diff --git a/tests/test_preview_command.py b/tests/test_preview_command.py index fd312ad..d3bf85d 100644 --- a/tests/test_preview_command.py +++ b/tests/test_preview_command.py @@ -115,6 +115,7 @@ def test_scan_hierarchy_and_ipm(tmp_path: Path): { "DESIGN_NAME": "user_proj_example", "VERILOG_FILES": ["dir::../../verilog/rtl/user_proj_example.v"], + "FP_SIZING": "absolute", "DIE_AREA": [0, 0, 2800, 1760], } ), @@ -131,6 +132,10 @@ def test_scan_hierarchy_and_ipm(tmp_path: Path): assert by_name["user_proj_example"]["instances"] == [ {"parent_macro": "user_project_wrapper", "count": 1} ] + assert by_name["user_proj_example"]["estimated_area_mm2"] == "4.9280" + assert by_name["user_proj_example"]["area_source"] == "die_area" + assert by_name["user_project_wrapper"]["estimated_area_mm2"] == "10.2784" + assert by_name["user_project_wrapper"]["area_source"] == "die_area" assert any(item["name"] == "CF_SRAM_1024x32" for item in scan["marketplace_ip"]) @@ -192,9 +197,24 @@ def test_parse_synth_metrics_and_skip_gates_for_wrapper(tmp_path: Path): "integration_strategy": STRATEGY_FLATTENED_RTL, "gate_count": None, "estimated_area_mm2": None, + "area_source": None, } apply_synth_metrics(leaf, metrics, lint_errors=0) assert leaf["gate_count"] == 1234 + assert leaf["estimated_area_mm2"] == "0.05" + assert leaf["area_source"] == "synthesis" + + fixed = { + "name": "user_proj_example", + "integration_strategy": STRATEGY_FLATTENED_RTL, + "gate_count": None, + "estimated_area_mm2": "4.9280", + "area_source": "die_area", + } + apply_synth_metrics(fixed, metrics, lint_errors=0) + assert fixed["gate_count"] == 1234 + assert fixed["estimated_area_mm2"] == "4.9280" + assert fixed["area_source"] == "die_area" def test_leaf_summary_partial_metrics(): @@ -227,6 +247,72 @@ def test_leaf_summary_partial_metrics(): assert summary["missing_gate_count_macro_count"] == 1 +def test_scan_applies_die_area_when_wrapper_config_is_seen_first(tmp_path: Path): + _init_project(tmp_path) + _write( + tmp_path / "openlane" / "a_wrapper" / "config.json", + json.dumps( + { + "DESIGN_NAME": "a_wrapper", + "SYNTH_ELABORATE_ONLY": True, + "MACROS": { + "user_proj_example": { + "gds": ["dir::../../gds/user_proj_example.gds"], + "lef": ["dir::../../lef/user_proj_example.lef"], + "instances": {"mprj": {}}, + } + }, + } + ), + ) + _write( + tmp_path / "openlane" / "user_proj_example" / "config.json", + json.dumps( + { + "DESIGN_NAME": "user_proj_example", + "VERILOG_FILES": ["a.v"], + "FP_SIZING": "absolute", + "DIE_AREA": "0 0 2800 1760", + } + ), + ) + scan = scan_project(tmp_path) + by_name = {row["name"]: row for row in scan["macros"]} + assert by_name["user_proj_example"]["config_path"].endswith( + "openlane/user_proj_example/config.json" + ) + assert by_name["user_proj_example"]["estimated_area_mm2"] == "4.9280" + assert by_name["user_proj_example"]["area_source"] == "die_area" + assert not any("Multiple configs declare" in note for note in scan["notes"]) + + +def test_relative_floorplan_area_is_replaced_by_synthesis(tmp_path: Path): + _init_project(tmp_path) + _write( + tmp_path / "openlane" / "user_proj_example" / "config.json", + json.dumps( + { + "DESIGN_NAME": "user_proj_example", + "VERILOG_FILES": ["a.v"], + "FP_SIZING": "relative", + "DIE_AREA": [0, 0, 2800, 1760], + } + ), + ) + scan = scan_project(tmp_path) + macro = scan["macros"][0] + assert macro["estimated_area_mm2"] == "4.9280" + assert macro["area_source"] is None + apply_synth_metrics( + macro, + {"gate_count": 40, "estimated_area_mm2": 0.02}, + lint_errors=0, + ) + assert macro["gate_count"] == 40 + assert macro["estimated_area_mm2"] == "0.02" + assert macro["area_source"] == "synthesis" + + def test_preview_skip_synth_no_push(tmp_path: Path): _init_project(tmp_path) _write(