Coverage for klayout_pex/kpex_cli.py: 67%
731 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-17 19:08 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-17 19:08 +0000
1#! /usr/bin/env python3
2#
3# --------------------------------------------------------------------------------
4# SPDX-FileCopyrightText: 2024-2025 Martin Jan Köhler and Harald Pretl
5# Johannes Kepler University, Institute for Integrated Circuits.
6#
7# This file is part of KPEX
8# (see https://github.com/iic-jku/klayout-pex).
9#
10# This program is free software: you can redistribute it and/or modify
11# it under the terms of the GNU General Public License as published by
12# the Free Software Foundation, either version 3 of the License, or
13# (at your option) any later version.
14#
15# This program is distributed in the hope that it will be useful,
16# but WITHOUT ANY WARRANTY; without even the implied warranty of
17# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18# GNU General Public License for more details.
19#
20# You should have received a copy of the GNU General Public License
21# along with this program. If not, see <http://www.gnu.org/licenses/>.
22# SPDX-License-Identifier: GPL-3.0-or-later
23# --------------------------------------------------------------------------------
24#
26import argparse
27import contextlib
28from datetime import datetime
29from enum import StrEnum
30import logging
31import os
32import os.path
33from pathlib import Path
34import rich.console
35import rich.markdown
36import rich.text
37from rich_argparse import RichHelpFormatter
38import shlex
39import shutil
40import sys
41from typing import *
43import klayout.db as kdb
44import klayout.rdb as rdb
46from .klayout import capacitance_matrix_interpreter
47from .common.path_validation import validate_files, FileValidationResult
48from .env import EnvVar, Env
49from .extraction_engine import ExtractionEngine
50from .fastercap.fastercap_input_builder import FasterCapInputBuilder
51from .fastercap.fastercap_model_generator import FasterCapModelGenerator
52from .fastercap.fastercap_runner import run_fastercap, fastercap_parse_capacitance_matrix
53from .fastercap.output_interpreter import FasterCapOutputInterpreter
54from .fastcap.fastcap_runner import run_fastcap, fastcap_parse_capacitance_matrix
55from .fastcap.output_interpreter import FastCapOutputInterpreter
56from .klayout.lvs_runner import LVSRunner
57from .klayout.lvsdb_extractor import KLayoutExtractionContext, KLayoutExtractedLayerInfo
58from .klayout.netlist_expander import NetlistExpander
59from .klayout.netlist_csv import NetlistCSVWriter
60from .klayout.netlist_printer import NetlistPrinter
61from .klayout.netlist_reducer import NetlistReducer
62from .klayout.repair_rdb import repair_rdb
63from .log import (
64 LogLevel,
65 set_log_level,
66 register_additional_handler,
67 deregister_additional_handler,
68 # console,
69 # debug,
70 info,
71 warning,
72 subproc,
73 error,
74 rule
75)
76from .magic.magic_ext_file_parser import parse_magic_pex_run
77from .magic.magic_runner import (
78 MagicPEXMode,
79 MagicShortMode,
80 MagicMergeMode,
81 MagicUniqueMode,
82 run_magic,
83 prepare_magic_script,
84)
85from .magic.magic_log_analyzer import MagicLogAnalyzer
86from .pex25d.artifact import (
87 ArtifactFormat,
88 ArtifactKind,
89 ArtifactNamingError,
90 infer_artifact_spec,
91)
92from .klayout.pex25d_builder import DEFAULT_GRID_UM
93from .pex25d.diagnostics import DiagnosticsReport, ExitCode, diagnostics_stream
94from .pex25d.pex25d_cli import Pex25DCLI
95from .pdk_config import PDK, PDKConfig
96from .rcx25.extractor import RCX25Extractor, ExtractionResults
97from .rcx25.netlist_expander import RCX25NetlistExpander
98from .rcx25.pex_mode import PEXMode
99from .tech_info import TechDefError, TechInfo
100from .tool_version_constraints import (
101 Tool,
102 VersionCheckMode,
103 check_tool_versions
104)
105from .util.multiple_choice import MultipleChoicePattern
106from .util.argparse_helpers import render_enum_help, true_or_false
107from .version import __version__
110# ------------------------------------------------------------------------------------
112PROGRAM_NAME = "kpex"
115class ArgumentValidationError(Exception):
116 pass
119class InputMode(StrEnum):
120 LVSDB = "lvsdb"
121 GDS = "gds"
124class KpexCLI:
125 # ---------------------------------------------------------- argument groups
126 #
127 # Each group is attached to a *given* parser rather than to one flat parser,
128 # so that a subcommand declares exactly the options it can act on. That is
129 # the point of the verb layer: `kpex pex25d` cannot be handed --magic_halo,
130 # and the validation below no longer has to hand-check which combinations
131 # of flags make sense together.
133 @staticmethod
134 def _add_special_options(parser: argparse.ArgumentParser,
135 include_threads: bool = False) -> None:
136 group_special = parser.add_argument_group("Special Options")
137 group_special.add_argument("--help", "-h", action='help',
138 help="show this help message and exit")
139 group_special.add_argument("--log_level", dest='log_level', default='subprocess',
140 help=render_enum_help(topic='log_level', enum_cls=LogLevel))
141 group_special.add_argument("--tool_version_checks", dest='version_check_mode',
142 default=VersionCheckMode.DEFAULT, type=VersionCheckMode,
143 choices=list(VersionCheckMode),
144 help=render_enum_help(topic='tool_version_checks',
145 enum_cls=VersionCheckMode))
146 if include_threads:
147 group_special.add_argument("--threads", dest='num_threads', type=int,
148 default=os.cpu_count() * 4,
149 help="number of threads (e.g. for FasterCap) "
150 "(default is %(default)s)")
152 @staticmethod
153 def _add_pex_setup_arguments(parser: argparse.ArgumentParser,
154 env: Env,
155 include_spice_output: bool = True) -> None:
156 group_pex = parser.add_argument_group("Parasitic Extraction Setup")
158 all_pdk_choices = list(PDK) + list(PDK.legacy_aliases().keys())
160 default_pdk = env.default_pdk
161 pdk_help = render_enum_help(topic='pdk', enum_cls=PDK, print_default=False)
162 if default_pdk:
163 pdk_help += f" (default is '{default_pdk}')"
165 group_pex.add_argument("--pdk", dest="pdk", required=default_pdk is None,
166 type=PDK.from_string, choices=all_pdk_choices,
167 help=pdk_help, default=default_pdk)
169 group_pex.add_argument("--out_dir", dest="output_dir_base_path", default="output",
170 help="Run directory path (default is '%(default)s')")
172 if include_spice_output:
173 group_pex.add_argument("--out_spice", "-o", dest="output_spice_path", default=None,
174 help="Optional additional SPICE output path (default is none)")
177 @staticmethod
178 def _add_pex_input_arguments(parser: argparse.ArgumentParser) -> None:
179 group_pex_input = parser.add_argument_group("Parasitic Extraction Input",
180 description="Either LVS is run, or an existing LVSDB is used")
181 group_pex_input.add_argument("--gds", "-g", dest="gds_path", default=None,
182 help="GDS path (for LVS)")
183 group_pex_input.add_argument("--schematic", "-s", dest="schematic_path",
184 help="Schematic SPICE netlist path (for LVS). "
185 "If none given, a dummy schematic will be created")
186 # Developer shortcut, not a supported entry point. It exists so that a
187 # PEX25D or engine run can be repeated without paying for LVS again while
188 # working on the code downstream of it. An LVSDB is tied to the kpex
189 # version and PDK that produced it and is not an interchange format —
190 # PEX25D is. Anyone who wants to hand a scene to somebody else, or keep
191 # one, should be writing PEX25D, not passing LVSDBs around.
192 group_pex_input.add_argument("--lvsdb", "-l", dest="lvsdb_path", default=None,
193 help="KLayout PEX-LVSDB path from a previous run "
194 "(bypass PEX-LVS). Developer shortcut for "
195 "re-running without repeating LVS; not an "
196 "interchange format — use PEX25D for that")
197 group_pex_input.add_argument("--cell", "-c", dest="cell_name", default=None,
198 help="Cell (default is the top cell)")
200 group_pex_input.add_argument("--cache-lvs", dest="cache_lvs",
201 type=true_or_false, default=True,
202 help="Used cached LVSDB (for given input GDS) (default is %(default)s)")
203 group_pex_input.add_argument("--cache-dir", dest="cache_dir_path", default=None,
204 help="Path for cached LVSDB (default is .kpex_cache within --out_dir)")
205 group_pex_input.add_argument("--lvs-verbose", dest="klayout_lvs_verbose",
206 type=true_or_false, default=False,
207 help="Verbose KLayout LVS output (default is %(default)s)")
210 @staticmethod
211 def _add_engine_arguments(parser: argparse.ArgumentParser) -> None:
212 group_pex_options = parser.add_argument_group("Extraction Engines")
213 group_pex_options.add_argument("--fastercap", dest="run_fastercap",
214 action='store_true', default=False,
215 help="Run FasterCap engine (default is %(default)s)")
216 group_pex_options.add_argument("--fastcap", dest="run_fastcap",
217 action='store_true', default=False,
218 help="Run FastCap2 engine (default is %(default)s)")
219 group_pex_options.add_argument("--magic", dest="run_magic",
220 action='store_true', default=False,
221 help="Run MAGIC engine (default is %(default)s)")
222 group_pex_options.add_argument("--2.5D", dest="run_2_5D",
223 action='store_true', default=False,
224 help="Run 2.5D analytical engine (default is %(default)s)")
227 @staticmethod
228 def _add_extraction_options(parser: argparse.ArgumentParser) -> None:
229 group_pex_options = parser.add_argument_group("Parasitic Extraction Options")
230 group_pex_options.add_argument("--blackbox", dest="blackbox_devices",
231 type=true_or_false, default=False, # TODO: in the future this should be True by default
232 help="Blackbox devices like MIM/MOM caps, as they are handled by SPICE models "
233 "(default is %(default)s for testing now)")
235 @staticmethod
236 def _add_tech_arguments(parser: argparse.ArgumentParser) -> None:
237 group_tech = parser.add_argument_group("Technology Options")
238 group_tech.add_argument("--diel", dest="dielectric_filter",
239 type=str, default="all",
240 help=f"Comma separated list of dielectric filter patterns. "
241 f"Allowed patterns are: (none, all, -dielname1, +dielname2) "
242 f"(default is %(default)s)")
244 @staticmethod
245 def _add_fastercap_arguments(parser: argparse.ArgumentParser) -> None:
246 group_fastercap = parser.add_argument_group("FasterCap Options")
247 group_fastercap.add_argument("--k_void", "-k", dest="k_void",
248 type=float, default=3.9,
249 help="Dielectric constant of void (default is %(default)s)")
251 # TODO: reflect that these are also now used by KPEX/2.5D engine!
252 group_fastercap.add_argument("--delaunay_amax", "-a", dest="delaunay_amax",
253 type=float, default=50,
254 help="Delaunay triangulation maximum area (default is %(default)s)")
255 group_fastercap.add_argument("--delaunay_b", "-b", dest="delaunay_b",
256 type=float, default=0.5,
257 help="Delaunay triangulation b (default is %(default)s)")
258 group_fastercap.add_argument("--geo_check", dest="geometry_check",
259 type=true_or_false, default=False,
260 help=f"Validate geometries before passing to FasterCap "
261 f"(default is False)")
262 group_fastercap.add_argument("--tolerance", dest="fastercap_tolerance",
263 type=float, default=0.05,
264 help="FasterCap -aX error tolerance (default is %(default)s)")
265 group_fastercap.add_argument("--d_coeff", dest="fastercap_d_coeff",
266 type=float, default=0.5,
267 help=f"FasterCap -d direct potential interaction coefficient to mesh refinement "
268 f"(default is %(default)s)")
269 group_fastercap.add_argument("--mesh", dest="fastercap_mesh_refinement_value",
270 type=float, default=0.5,
271 help="FasterCap -m Mesh relative refinement value (default is %(default)s)")
272 group_fastercap.add_argument("--ooc", dest="fastercap_ooc_condition",
273 type=float, default=2,
274 help="FasterCap -f out-of-core free memory to link memory condition "
275 "(0 = don't go OOC, default is %(default)s)")
276 group_fastercap.add_argument("--auto_precond", dest="fastercap_auto_preconditioner",
277 type=true_or_false, default=True,
278 help=f"FasterCap -ap Automatic preconditioner usage (default is %(default)s)")
279 group_fastercap.add_argument("--galerkin", dest="fastercap_galerkin_scheme",
280 action='store_true', default=False,
281 help=f"FasterCap -g Use Galerkin scheme (default is %(default)s)")
282 group_fastercap.add_argument("--jacobi", dest="fastercap_jacobi_preconditioner",
283 action='store_true', default=False,
284 help="FasterCap -pj Use Jacobi preconditioner (default is %(default)s)")
287 @staticmethod
288 def _add_magic_arguments(parser: argparse.ArgumentParser, env: Env) -> None:
289 group_magic = parser.add_argument_group("MAGIC Options")
291 default_magicrc_path = env.default_magicrc_path
292 if default_magicrc_path:
293 magicrc_help = f"Path to magicrc configuration file (default is '{default_magicrc_path}')"
294 else:
295 magicrc_help = "Path to magicrc configuration file "\
296 "(default not available, PDK and PDK_ROOT must be set!)"
298 group_magic.add_argument('--magicrc', dest='magicrc_path', default=default_magicrc_path,
299 help=magicrc_help)
300 group_magic.add_argument("--magic_mode", dest='magic_pex_mode',
301 default=MagicPEXMode.DEFAULT, type=MagicPEXMode, choices=list(MagicPEXMode),
302 help=render_enum_help(topic='magic_mode', enum_cls=MagicPEXMode))
303 group_magic.add_argument("--magic_cthresh", dest="magic_cthresh",
304 type=float, default=0.01,
305 help="Threshold (in fF) for ignored parasitic capacitances (default is %(default)s). "
306 "(MAGIC command: ext2spice cthresh <value>)")
307 group_magic.add_argument("--magic_rthresh", dest="magic_rthresh",
308 type=int, default=100,
309 help="Threshold (in Ω) for ignored parasitic resistances (default is %(default)s). "
310 "(MAGIC command: ext2spice rthresh <value>)")
311 group_magic.add_argument("--magic_thresh", dest="magic_thresh",
312 type=int, default=None,
313 help="Lumped resistance threshold (in mΩ) to trigger network extraction. "
314 "If not supplied, MAGIC's default is implicitly used. "
315 "(MAGIC command: extresist threshold <value>)")
316 group_magic.add_argument("--magic_minres", dest="magic_minres",
317 type=int, default=None,
318 help="Threshold (in mΩ) for removing individual resistors during network simplification. "
319 "If not supplied, MAGIC's default is implicitly used. "
320 "(MAGIC command: extresist minres <value>)")
321 group_magic.add_argument("--magic_mindel", dest="magic_mindel",
322 type=int, default=None,
323 help="Minimum delay threshold (in ps) to trigger network extraction. "
324 "If not supplied, MAGIC's default is implicitly used. "
325 "(MAGIC command: extresist mindelay <value>)")
326 group_magic.add_argument("--magic_halo", dest="magic_halo",
327 type=float, default=None,
328 help="Custom sidewall halo distance (in µm) "
329 "(MAGIC command: extract halo <value>) (default is no custom halo)")
330 group_magic.add_argument("--magic_short", dest='magic_short_mode',
331 default=MagicShortMode.DEFAULT, type=MagicShortMode, choices=list(MagicShortMode),
332 help=render_enum_help(topic='magic_short', enum_cls=MagicShortMode))
333 group_magic.add_argument("--magic_merge", dest='magic_merge_mode',
334 default=MagicMergeMode.DEFAULT, type=MagicMergeMode, choices=list(MagicMergeMode),
335 help=render_enum_help(topic='magic_merge', enum_cls=MagicMergeMode))
336 group_magic.add_argument("--magic_pre_flatten", dest='magic_pre_flatten',
337 action='store_true', default=False,
338 help="Flatten GDS before reading it with Magic (default is %(default)s).")
339 group_magic.add_argument("--magic_unique", dest='magic_unique_mode',
340 default=MagicUniqueMode.DEFAULT, type=MagicUniqueMode, choices=list(MagicUniqueMode),
341 help="Control net uniqueness during extraction "
342 " ('implicit' omits the command, uses MAGIC's default value). "
343 + render_enum_help(topic='magic_unique', enum_cls=MagicUniqueMode))
344 group_magic.add_argument("--magic_analyze", dest='magic_analyze',
345 type=true_or_false, default=False,
346 help="Analyze MAGIC extraction files (report as KLayout RDB)")
348 @staticmethod
349 def _add_analytical_25d_arguments(parser: argparse.ArgumentParser) -> None:
350 group_25d = parser.add_argument_group("2.5D Options")
351 group_25d.add_argument("--mode", dest='pex_mode',
352 default=PEXMode.DEFAULT, type=PEXMode, choices=list(PEXMode),
353 help=render_enum_help(topic='mode', enum_cls=PEXMode))
354 group_25d.add_argument("--halo", dest="halo",
355 type=float, default=None,
356 help="Custom sidewall halo distance (in µm) to override tech info "
357 "(default is no custom halo)")
358 group_25d.add_argument("--scale", dest="scale_ratio_to_fit_halo",
359 type=true_or_false, default=True,
360 help=f"Scale fringe ratios, so that halo distance is 100%% (default is %(default)s)")
363 @staticmethod
364 def _add_pex25d_output_arguments(parser: argparse.ArgumentParser) -> None:
365 group_out = parser.add_argument_group(
366 "PEX25D Output",
367 description="At least one of --output_file / --output_scene must be given. "
368 "They are two options rather than one --emit switch because the "
369 "two artifacts are different things with different consumers, and "
370 "a single switch would leave it unclear where each one lands."
371 )
372 group_out.add_argument("--output_file", dest='pex25d_file_path',
373 default=None, metavar='PATH',
374 help="Write the unresolved PEX25DFile here ('-' for stdout). "
375 "The literal form: one message per PEX25D record, "
376 "references still plain strings, nothing derived. This is "
377 "what a reader produces and what a validator checks.")
378 group_out.add_argument("--output_scene", dest='pex25d_scene_path',
379 default=None, metavar='PATH',
380 help="Write the resolved PEX25DScene here ('-' for stdout). "
381 "The adapter-ready form: absolute z extents, CONNECTS / "
382 "BETWEEN / WRAPS resolved, wrap depth flattened, terminal "
383 "intersections computed.")
384 group_out.add_argument("--format", dest='pex25d_format',
385 default=ArtifactFormat.AUTO, type=ArtifactFormat,
386 choices=list(ArtifactFormat),
387 help="Override the encoding otherwise inferred from the output "
388 "file names (default is '%(default)s'). Naming convention: "
389 "NAME.pex25d is text, NAME.pex25d.pb is binary protobuf, "
390 "NAME.pex25d.textpb is protobuf text format, and a .scene "
391 "infix marks a resolved scene. A trailing .gz is honoured.")
392 group_out.add_argument("--grid", dest='pex25d_grid',
393 default=DEFAULT_GRID_UM, metavar='UM',
394 help="Coordinate grid in µm (default is %(default)s). "
395 "Every z offset, thickness and coordinate must be "
396 "an integer multiple of it, and it must divide the "
397 "layout DBU exactly.")
398 group_out.add_argument("--domain_margin", dest='pex25d_domain_margin',
399 type=float, default=None, metavar='UM',
400 help="Emit DOMAIN_MARGIN with this clearance in µm "
401 "(default is none, leaving the domain unset for "
402 "the solver adapter to choose)")
403 group_out.add_argument("--validate", dest='pex25d_validate',
404 type=true_or_false, default=True,
405 help="Check the generated artifacts against the reference "
406 "validator before reporting (default is %(default)s). "
407 "A defect in the technology data — a profile declared "
408 "twice, a film anchored on the wrong link — otherwise "
409 "shows up only when somebody runs 'pex25d validate' on "
410 "the result. The artifacts are written either way; the "
411 "diagnostics decide the exit code.")
412 group_out.add_argument("--comments", dest='pex25d_comments',
413 type=true_or_false, default=False,
414 help="Include the syntax hints from the specification as "
415 "comments (default is %(default)s). Text format only — "
416 "the protobuf encodings have no comments.")
417 group_out.add_argument("--with_source_refs", dest='pex25d_with_source_refs',
418 type=true_or_false, default=False,
419 help="Populate every SourceRef (default is %(default)s). "
420 "Protobuf output formats only — the text format has no "
421 "spelling for a source reference. Roughly doubles the size "
422 "of a layout-scale file, and means little for geometry "
423 "built in memory rather than parsed from text; useful "
424 "mainly when diffing generated output against a "
425 "hand-written file.")
427 # ------------------------------------------------------------------ parsing
429 @staticmethod
430 def parse_args(arg_list: List[str],
431 env: Env) -> argparse.Namespace:
432 # epilog = f"See '{PROGRAM_NAME} <subcommand> -h' for help on subcommand"
433 epilog = EnvVar.help_epilog_table()
434 epilog_md = rich.console.Group(
435 rich.text.Text('Environmental variables:', style='argparse.groups'),
436 rich.markdown.Markdown(epilog, style='argparse.text')
437 )
438 main_parser = argparse.ArgumentParser(prog=PROGRAM_NAME,
439 description=f"{PROGRAM_NAME}: "
440 f"KLayout-integrated Parasitic Extraction Tool",
441 epilog=epilog_md,
442 add_help=False,
443 formatter_class=RichHelpFormatter)
445 group_special = main_parser.add_argument_group("Special Options")
446 group_special.add_argument("--help", "-h", action='help',
447 help="show this help message and exit")
448 group_special.add_argument("--version", "-v", action='version',
449 version=f'{PROGRAM_NAME} {__version__}')
451 subparsers = main_parser.add_subparsers(dest="command", metavar='<subcommand>',
452 help="Sub-commands help")
454 parser_extract = subparsers.add_parser(
455 "extract",
456 help="Run one or more parasitic extraction engines",
457 description="Run PEX end to end: LVS for connectivity, then the selected "
458 "engine(s), then netlist expansion and reduction.",
459 add_help=False, formatter_class=RichHelpFormatter, epilog=epilog_md)
460 KpexCLI._add_special_options(parser_extract, include_threads=True)
461 KpexCLI._add_pex_setup_arguments(parser_extract, env)
462 KpexCLI._add_pex_input_arguments(parser_extract)
463 KpexCLI._add_engine_arguments(parser_extract)
464 KpexCLI._add_extraction_options(parser_extract)
465 KpexCLI._add_tech_arguments(parser_extract)
466 KpexCLI._add_fastercap_arguments(parser_extract)
467 KpexCLI._add_magic_arguments(parser_extract, env)
468 KpexCLI._add_analytical_25d_arguments(parser_extract)
470 parser_pex25d = subparsers.add_parser(
471 "pex25d",
472 help="Generate PEX25D solver input from a layout, without running a solver",
473 description="Run LVS for connectivity and write the scene as PEX25D, stopping "
474 "before any engine is started. This is the cut point in the "
475 "pipeline that lets the geometry be inspected, archived, diffed, "
476 "or handed to somebody else's solver. Format-only work on the "
477 "result — validating, re-encoding, exporting to an engine's "
478 "native input — is the standalone 'pex25d' tool's job.",
479 add_help=False, formatter_class=RichHelpFormatter, epilog=epilog_md)
480 KpexCLI._add_special_options(parser_pex25d)
481 KpexCLI._add_pex_setup_arguments(parser_pex25d, env, include_spice_output=False)
482 KpexCLI._add_pex_input_arguments(parser_pex25d)
483 KpexCLI._add_extraction_options(parser_pex25d)
484 KpexCLI._add_tech_arguments(parser_pex25d)
485 KpexCLI._add_pex25d_output_arguments(parser_pex25d)
486 Pex25DCLI._add_diagnostics_arguments(parser_pex25d)
488 if arg_list is None:
489 arg_list = sys.argv[1:]
490 args = main_parser.parse_args(arg_list)
491 if args.command is None:
492 main_parser.print_help()
493 sys.exit(ExitCode.USAGE)
495 # The engine switches only exist on 'extract'. Defaulting them here keeps
496 # the shared validation and logging paths free of subcommand checks.
497 for engine_attr in ('run_magic', 'run_fastcap', 'run_fastercap', 'run_2_5D'):
498 if not hasattr(args, engine_attr):
499 setattr(args, engine_attr, False)
501 # environmental variables and their defaults
502 args.fastcap_exe_path = env[EnvVar.FASTCAP_EXE]
503 args.fastercap_exe_path = env[EnvVar.FASTERCAP_EXE]
504 args.klayout_exe_path = env[EnvVar.KLAYOUT_EXE]
505 args.magic_exe_path = env[EnvVar.MAGIC_EXE]
507 return args
509 @staticmethod
510 def validate_args(args: argparse.Namespace):
511 found_errors = False
513 pdk_config: PDKConfig = args.pdk.config
514 args.tech_pbjson_path = pdk_config.tech_pb_json_path
515 args.lvs_script_path = pdk_config.pex_lvs_script_path
517 def input_file_stem(path: str):
518 # could be *.gds, or *.gds.gz, so remove all extensions
519 return os.path.basename(path).split(sep='.')[0]
521 if not os.path.isfile(args.tech_pbjson_path):
522 error(f"Can't read technology file at path {args.tech_pbjson_path}")
523 found_errors = True
525 if not os.path.isfile(args.lvs_script_path):
526 error(f"Can't locate LVS script path at {args.lvs_script_path}")
527 found_errors = True
529 rule('Input Layout')
531 # Which engines may be combined with which inputs is a question only
532 # 'extract' has; a dump run has no engine to disagree with.
533 if args.command == 'extract':
534 # check engines VS input possiblities
535 match (args.run_magic, args.run_fastcap, args.run_fastercap, args.run_2_5D,
536 args.gds_path, args.lvsdb_path):
537 case (True, _, _, _, None, _):
538 error(f"Running PEX engine MAGIC requires --gds (--lvsdb not possible)")
539 found_errors = True
540 case (False, False, False, False, _, _): # at least one engine must be activated
541 error("No PEX engines activated")
542 engine_help = """
543 | Argument | Description |
544 | ------------ | ------------------------------- |
545 | --2.5D | Run KPEX/2.5D analytical engine |
546 | --fastcap | Run KPEX/FastCap2 3D engine |
547 | --fastercap | Run KPEX/FasterCap 3D engine |
548 | --magic | Run MAGIC wrapper engine |
549 """
550 subproc(f"\n\nPlease activate one or more engines using the arguments:")
551 rich.print(rich.markdown.Markdown(engine_help, style='argparse.text'))
552 found_errors = True
553 case (_, _, _, _, None, None):
554 error(f"Neither GDS nor LVSDB was provided")
555 found_errors = True
557 # check if we find magicrc
558 if args.run_magic:
559 if args.magicrc_path is None:
560 error(f"magicrc not available, requires any those:\n"
561 f"\t• set environmental variables PDK_ROOT / PDK\n"
562 f"\t• pass argument --magicrc")
563 found_errors = True
564 else:
565 result = validate_files([args.magicrc_path])
566 for f in result.failures:
567 error(f"Invalid magicrc: {f.reason} at {str(f.path)}")
568 found_errors = True
570 # input mode: LVS or existing LVSDB?
571 if args.gds_path:
572 info(f"GDS input file passed, running in LVS mode")
573 args.input_mode = InputMode.GDS
574 if not os.path.isfile(args.gds_path):
575 error(f"Can't read GDS file (LVS input) at path {args.gds_path}")
576 found_errors = True
577 else:
578 args.layout = kdb.Layout()
579 args.layout.read(args.gds_path)
581 top_cells = args.layout.top_cells()
583 if args.cell_name: # explicit user-specified cell name
584 args.effective_cell_name = args.cell_name
586 found_cell: Optional[kdb.Cell] = None
587 for cell in args.layout.cells('*'):
588 if cell.name == args.effective_cell_name:
589 found_cell = cell
590 break
591 if not found_cell:
592 error(f"Could not find cell {args.cell_name} in GDS {args.gds_path}")
593 found_errors = True
595 is_only_top_cell = len(top_cells) == 1 and top_cells[0].name == args.cell_name
596 if is_only_top_cell:
597 info(f"Found cell {args.cell_name} in GDS {args.gds_path} (only top cell)")
598 else: # there are other cells => extract the top cell to a tmp layout
599 run_dir_id = f"{input_file_stem(args.gds_path)}__{args.effective_cell_name}"
600 args.output_dir_path = os.path.join(args.output_dir_base_path, run_dir_id)
601 os.makedirs(args.output_dir_path, exist_ok=True)
602 args.effective_gds_path = os.path.join(args.output_dir_path,
603 f"{args.cell_name}_exported.gds.gz")
604 info(f"Found cell {args.cell_name} in GDS {args.gds_path}, "
605 f"but it is not the only top cell, "
606 f"so layout is exported to: {args.effective_gds_path}")
608 found_cell.write(args.effective_gds_path)
609 else: # find top cell
610 if len(top_cells) == 1:
611 args.effective_cell_name = top_cells[0].name
612 info(f"No explicit top cell specified, using top cell '{args.effective_cell_name}'")
613 else:
614 args.effective_cell_name = 'TOP'
615 error(f"Could not determine the default top cell in GDS {args.gds_path}, "
616 f"there are multiple: {', '.join([c.name for c in top_cells])}. "
617 f"Use --cell to specify the cell")
618 found_errors = True
620 if not hasattr(args, 'effective_gds_path'):
621 args.effective_gds_path = args.gds_path
622 elif args.lvsdb_path is not None:
623 info(f"LVSDB input file passed, bypassing LVS")
624 args.input_mode = InputMode.LVSDB
625 if not os.path.isfile(args.lvsdb_path):
626 error(f"Can't read KLayout LVSDB file at path {args.lvsdb_path}")
627 found_errors = True
628 else:
629 lvsdb = kdb.LayoutVsSchematic()
630 lvsdb.read(args.lvsdb_path)
631 top_cell: kdb.Cell = lvsdb.internal_top_cell()
632 args.effective_cell_name = top_cell.name
634 if hasattr(args, 'effective_cell_name'):
635 run_dir_id: str
636 match args.input_mode:
637 case InputMode.GDS:
638 run_dir_id = f"{input_file_stem(args.gds_path)}__{args.effective_cell_name}"
639 case InputMode.LVSDB:
640 run_dir_id = f"{input_file_stem(args.lvsdb_path)}__{args.effective_cell_name}"
641 case _:
642 raise NotImplementedError(f"Unknown input mode {args.input_mode}")
644 args.output_dir_path = os.path.join(args.output_dir_base_path, run_dir_id)
645 os.makedirs(args.output_dir_path, exist_ok=True)
646 if args.input_mode == InputMode.GDS:
647 if args.schematic_path:
648 args.effective_schematic_path = args.schematic_path
649 if not os.path.isfile(args.schematic_path):
650 error(f"Can't read schematic (LVS input) at path {args.schematic_path}")
651 found_errors = True
652 else:
653 info(f"LVS input schematic not specified (argument --schematic), using dummy schematic")
654 args.effective_schematic_path = os.path.join(args.output_dir_path,
655 f"{args.effective_cell_name}_dummy_schematic.spice")
656 with open(args.effective_schematic_path, 'w', encoding='utf-8') as f:
657 f.writelines([
658 f".subckt {args.effective_cell_name} VDD VSS\n",
659 '.ends\n',
660 '.end\n'
661 ])
663 try:
664 args.log_level = LogLevel[args.log_level.upper()]
665 except KeyError:
666 error(f"Requested log level {args.log_level.lower()} does not exist, "
667 f"{render_enum_help(topic='log_level', enum_cls=LogLevel, print_default=False)}")
668 found_errors = True
670 try:
671 pattern_string: str = args.dielectric_filter
672 args.dielectric_filter = MultipleChoicePattern(pattern=pattern_string)
673 except ValueError as e:
674 error("Failed to parse --diel arg", e)
675 found_errors = True
677 if args.cache_dir_path is None:
678 args.cache_dir_path = os.path.join(args.output_dir_base_path, '.kpex_cache')
680 if args.command == 'pex25d':
681 if args.pex25d_file_path is None and args.pex25d_scene_path is None:
682 error("Nothing to write. Give --output_file (the unresolved PEX25DFile), "
683 "--output_scene (the resolved PEX25DScene), or both.")
684 found_errors = True
686 if args.pex25d_file_path is not None \
687 and args.pex25d_file_path == args.pex25d_scene_path:
688 error("--output_file and --output_scene point at the same destination")
689 found_errors = True
691 # Resolved here rather than at write time so that a mistyped output
692 # name fails now, and not after a multi-minute LVS run.
693 args.pex25d_file_spec = None
694 args.pex25d_scene_spec = None
695 try:
696 if args.pex25d_file_path is not None:
697 args.pex25d_file_spec = infer_artifact_spec(
698 args.pex25d_file_path,
699 kind=ArtifactKind.FILE,
700 format=args.pex25d_format,
701 default_format=ArtifactFormat.TEXT)
702 if args.pex25d_scene_path is not None:
703 args.pex25d_scene_spec = infer_artifact_spec(
704 args.pex25d_scene_path,
705 kind=ArtifactKind.SCENE,
706 format=args.pex25d_format,
707 default_format=ArtifactFormat.PB)
708 except ArtifactNamingError as e:
709 error(str(e))
710 found_errors = True
712 # KLayout runs the LVS script, which the MAGIC wrapper does not need
713 # ("no need to run LVS etc if only running magic engine") and which an
714 # LVSDB input has already been through.
715 needs_lvs = getattr(args, 'input_mode', None) == InputMode.GDS and \
716 (args.command != 'extract' or
717 args.run_fastcap or args.run_fastercap or args.run_2_5D)
719 exe_path_by_tool: Dict[Tool, str] = {}
720 if needs_lvs:
721 exe_path_by_tool[Tool.KLAYOUT] = args.klayout_exe_path
722 if args.run_magic:
723 exe_path_by_tool[Tool.MAGIC] = args.magic_exe_path
724 if args.run_fastercap:
725 exe_path_by_tool[Tool.FASTERCAP] = args.fastercap_exe_path
726 if args.run_fastcap:
727 exe_path_by_tool[Tool.FASTCAP] = args.fastcap_exe_path
729 # Only the tools this run will actually start have to be there.
730 for tool, exe_path in list(exe_path_by_tool.items()):
731 if not (os.path.isfile(exe_path) or shutil.which(exe_path)):
732 error(f"Can't locate {tool.value} executable at {exe_path} "
733 f"(see {tool.env_var.value})")
734 found_errors = True
735 del exe_path_by_tool[tool]
737 if not check_tool_versions(exe_path_by_tool=exe_path_by_tool,
738 pdk=args.pdk,
739 mode=args.version_check_mode):
740 found_errors = True
742 if found_errors:
743 raise ArgumentValidationError("Argument validation failed")
745 def create_netlist_printer(self,
746 args: argparse.Namespace,
747 extraction_engine: ExtractionEngine):
748 printer = NetlistPrinter(extraction_engine=extraction_engine,
749 pdk=args.pdk)
750 return printer
752 def build_fastercap_input(self,
753 args: argparse.Namespace,
754 pex_context: KLayoutExtractionContext,
755 tech_info: TechInfo) -> str:
756 rule('Process stackup')
757 fastercap_input_builder = FasterCapInputBuilder(pex_context=pex_context,
758 tech_info=tech_info,
759 k_void=args.k_void,
760 delaunay_amax=args.delaunay_amax,
761 delaunay_b=args.delaunay_b)
762 gen: FasterCapModelGenerator = fastercap_input_builder.build()
764 rule('FasterCap Input File Generation')
765 faster_cap_input_dir_path = os.path.join(args.output_dir_path, 'FasterCap_Input_Files')
766 os.makedirs(faster_cap_input_dir_path, exist_ok=True)
768 lst_file = gen.write_fastcap(output_dir_path=faster_cap_input_dir_path, prefix='FasterCap_Input_')
770 rule('STL File Generation')
771 geometry_dir_path = os.path.join(args.output_dir_path, 'Geometries')
772 os.makedirs(geometry_dir_path, exist_ok=True)
773 gen.dump_stl(output_dir_path=geometry_dir_path, prefix='')
775 if args.geometry_check:
776 rule('Geometry Validation')
777 gen.check()
779 return lst_file
782 def run_fastercap_extraction(self,
783 args: argparse.Namespace,
784 pex_context: KLayoutExtractionContext,
785 lst_file: str):
786 rule('FasterCap Execution')
787 info(f"Configure number of OpenMP threads (environmental variable OMP_NUM_THREADS) as {args.num_threads}")
788 os.environ['OMP_NUM_THREADS'] = f"{args.num_threads}"
790 log_path = os.path.join(args.output_dir_path, f"{args.effective_cell_name}_FasterCap_Output.txt")
791 raw_csv_path = os.path.join(args.output_dir_path, f"{args.effective_cell_name}_FasterCap_Result_Matrix_Raw.csv")
792 avg_csv_path = os.path.join(args.output_dir_path, f"{args.effective_cell_name}_FasterCap_Result_Matrix_Avg.csv")
793 expanded_netlist_path = os.path.join(args.output_dir_path,
794 f"{args.effective_cell_name}_FasterCap_Expanded_Netlist.cir")
795 expanded_netlist_csv_path = os.path.join(args.output_dir_path,
796 f"{args.effective_cell_name}_FasterCap_Expanded_Netlist.csv")
797 reduced_netlist_path = os.path.join(args.output_dir_path, f"{args.effective_cell_name}_FasterCap_Reduced_Netlist.cir")
799 run_fastercap(exe_path=args.fastercap_exe_path,
800 lst_file_path=lst_file,
801 log_path=log_path,
802 tolerance=args.fastercap_tolerance,
803 d_coeff=args.fastercap_d_coeff,
804 mesh_refinement_value=args.fastercap_mesh_refinement_value,
805 ooc_condition=args.fastercap_ooc_condition,
806 auto_preconditioner=args.fastercap_auto_preconditioner,
807 galerkin_scheme=args.fastercap_galerkin_scheme,
808 jacobi_preconditioner=args.fastercap_jacobi_preconditioner)
810 cap_matrix = fastercap_parse_capacitance_matrix(log_path)
811 cap_matrix.write_csv(raw_csv_path)
813 cap_matrix = cap_matrix.averaged_off_diagonals()
814 cap_matrix.write_csv(avg_csv_path)
816 cap_matrix_interpreter = FasterCapOutputInterpreter()
818 netlist_expander = NetlistExpander()
819 expanded_netlist = netlist_expander.expand(
820 extracted_netlist=pex_context.lvsdb.netlist(),
821 top_cell_name=pex_context.annotated_top_cell.name,
822 cap_matrix=cap_matrix,
823 cap_matrix_interpreter=cap_matrix_interpreter,
824 blackbox_devices=args.blackbox_devices
825 )
827 # create a nice CSV for reports, useful for spreadsheets
828 netlist_csv_writer = NetlistCSVWriter()
829 netlist_csv_writer.write_csv(netlist=expanded_netlist,
830 top_cell_name=pex_context.annotated_top_cell.name,
831 output_path=expanded_netlist_csv_path)
833 rule("Extended netlist (CSV format):")
834 with open(expanded_netlist_csv_path, 'r') as f:
835 for line in f.readlines():
836 subproc(line[:-1]) # abusing subproc, simply want verbatim
837 rule()
839 info(f"Wrote expanded netlist CSV to: {expanded_netlist_csv_path}")
841 netlist_printer = self.create_netlist_printer(args, ExtractionEngine.FASTERCAP)
842 netlist_printer.write(expanded_netlist, expanded_netlist_path)
843 info(f"Wrote expanded netlist to: {expanded_netlist_path}")
845 # FIXME: should this be already reduced?
846 if args.output_spice_path:
847 netlist_printer.write(expanded_netlist, args.output_spice_path)
848 info(f"Copied expanded SPICE netlist to: {args.output_spice_path}")
850 netlist_reducer = NetlistReducer()
851 reduced_netlist = netlist_reducer.reduce(netlist=expanded_netlist,
852 top_cell_name=pex_context.annotated_top_cell.name)
853 netlist_printer.write(reduced_netlist, reduced_netlist_path)
854 info(f"Wrote reduced netlist to: {reduced_netlist_path}")
856 self._fastercap_extracted_csv_path = expanded_netlist_csv_path
858 def run_magic_extraction(self,
859 args: argparse.Namespace):
860 if args.input_mode != InputMode.GDS:
861 error(f"MAGIC engine only works with GDS input mode"
862 f" (currently {args.input_mode})")
863 return
865 magic_run_dir = os.path.join(args.output_dir_path, f"magic_{args.magic_pex_mode}")
866 magic_log_path = os.path.join(magic_run_dir,
867 f"{args.effective_cell_name}_MAGIC_{args.magic_pex_mode}_Output.txt")
868 magic_script_path = os.path.join(magic_run_dir,
869 f"{args.effective_cell_name}_MAGIC_{args.magic_pex_mode}_Script.tcl")
871 output_netlist_path = os.path.join(magic_run_dir, f"{args.effective_cell_name}.pex.spice")
872 report_db_path = os.path.join(magic_run_dir, f"{args.effective_cell_name}_MAGIC_report.rdb.gz")
874 os.makedirs(magic_run_dir, exist_ok=True)
876 layout = kdb.Layout()
877 layout.read(args.effective_gds_path)
879 if args.magic_pre_flatten:
880 tops = list(layout.top_cells())
881 if not tops:
882 raise Exception("No top cells found in the input layout.")
883 for top in tops:
884 top.flatten(True)
885 flattened_gds_path=os.path.join(magic_run_dir, f"{args.effective_cell_name}.gds")
886 layout.write(flattened_gds_path)
887 gds_path = flattened_gds_path
888 else:
889 gds_path = args.effective_gds_path
891 prepare_magic_script(gds_path=gds_path,
892 cell_name=args.effective_cell_name,
893 run_dir_path=magic_run_dir,
894 script_path=magic_script_path,
895 output_netlist_path=output_netlist_path,
896 pex_mode=args.magic_pex_mode,
897 c_threshold=args.magic_cthresh,
898 r_threshold=args.magic_rthresh,
899 threshold=args.magic_thresh,
900 min_res=args.magic_minres,
901 min_delay=args.magic_mindel,
902 halo=args.magic_halo,
903 short_mode=args.magic_short_mode,
904 merge_mode=args.magic_merge_mode,
905 unique_mode=args.magic_unique_mode)
907 run_magic(exe_path=args.magic_exe_path,
908 magicrc_path=args.magicrc_path,
909 script_path=magic_script_path,
910 log_path=magic_log_path)
912 magic_pex_run = parse_magic_pex_run(Path(magic_run_dir))
914 if args.magic_analyze:
915 report = rdb.ReportDatabase('')
916 magic_log_analyzer = MagicLogAnalyzer(magic_pex_run=magic_pex_run,
917 report=report,
918 dbu=layout.dbu)
919 magic_log_analyzer.analyze()
920 report.save(report_db_path)
922 rule("Paths")
923 if args.magic_analyze:
924 subproc(f"Report DB saved at: {report_db_path}")
925 subproc(f"SPICE netlist saved at: {output_netlist_path}")
927 if os.path.exists(output_netlist_path):
928 if args.output_spice_path and os.path.exists(output_netlist_path):
929 shutil.copy(output_netlist_path, args.output_spice_path)
930 info(f"Copied expanded SPICE netlist to: {args.output_spice_path}")
932 rule("MAGIC PEX SPICE netlist")
933 with open(output_netlist_path, 'r') as f:
934 subproc(f.read())
935 rule()
937 def run_fastcap_extraction(self,
938 args: argparse.Namespace,
939 pex_context: KLayoutExtractionContext,
940 lst_file: str):
941 rule('FastCap2 Execution')
943 log_path = os.path.join(args.output_dir_path, f"{args.effective_cell_name}_FastCap2_Output.txt")
944 raw_csv_path = os.path.join(args.output_dir_path, f"{args.effective_cell_name}_FastCap2_Result_Matrix_Raw.csv")
945 avg_csv_path = os.path.join(args.output_dir_path, f"{args.effective_cell_name}_FastCap2_Result_Matrix_Avg.csv")
946 expanded_netlist_path = os.path.join(args.output_dir_path,
947 f"{args.effective_cell_name}_FastCap2_Expanded_Netlist.cir")
948 reduced_netlist_path = os.path.join(args.output_dir_path,
949 f"{args.effective_cell_name}_FastCap2_Reduced_Netlist.cir")
951 run_fastcap(exe_path=args.fastcap_exe_path,
952 lst_file_path=lst_file,
953 log_path=log_path)
955 cap_matrix = fastcap_parse_capacitance_matrix(log_path)
956 cap_matrix.write_csv(raw_csv_path)
958 cap_matrix = cap_matrix.averaged_off_diagonals()
959 cap_matrix.write_csv(avg_csv_path)
961 cap_matrix_interpreter = FastCapOutputInterpreter()
963 netlist_expander = NetlistExpander()
964 expanded_netlist = netlist_expander.expand(
965 extracted_netlist=pex_context.lvsdb.netlist(),
966 top_cell_name=pex_context.annotated_top_cell.name,
967 cap_matrix=cap_matrix,
968 cap_matrix_interpreter=cap_matrix_interpreter,
969 blackbox_devices=args.blackbox_devices
970 )
972 netlist_printer = self.create_netlist_printer(args, ExtractionEngine.FASTCAP2)
973 netlist_printer.write(expanded_netlist, expanded_netlist_path)
974 info(f"Wrote expanded netlist to: {expanded_netlist_path}")
976 # FIXME: should this be already reduced?
977 if args.output_spice_path:
978 netlist_printer.write(expanded_netlist, args.output_spice_path)
979 info(f"Copied expanded SPICE netlist to: {args.output_spice_path}")
981 netlist_reducer = NetlistReducer()
982 reduced_netlist = netlist_reducer.reduce(netlist=expanded_netlist,
983 top_cell_name=pex_context.annotated_top_cell.name)
984 netlist_printer.write(reduced_netlist, reduced_netlist_path)
986 info(f"Wrote reduced netlist to: {reduced_netlist_path}")
988 def run_kpex_2_5d_engine(self,
989 args: argparse.Namespace,
990 pex_context: KLayoutExtractionContext,
991 tech_info: TechInfo,
992 report_path: str,
993 netlist_csv_path: Optional[str],
994 expanded_netlist_path: Optional[str]):
995 # TODO: make this separatly configurable
996 # for now we use 0
997 args.rcx25d_delaunay_amax = 0
998 args.rcx25d_delaunay_b = 0.5
1000 extractor = RCX25Extractor(pex_context=pex_context,
1001 pex_mode=args.pex_mode,
1002 delaunay_amax=args.rcx25d_delaunay_amax,
1003 delaunay_b=args.rcx25d_delaunay_b,
1004 scale_ratio_to_fit_halo=args.scale_ratio_to_fit_halo,
1005 tech_info=tech_info,
1006 report_path=report_path)
1007 extraction_results = extractor.extract()
1009 if netlist_csv_path is not None:
1010 # TODO: merge this with klayout_pex/klayout/netlist_csv.py
1012 with open(netlist_csv_path, 'w', encoding='utf-8') as f:
1013 summary = extraction_results.summarize()
1015 f.write('Device;Net1;Net2;Capacitance [fF];Resistance [Ω]\n')
1016 for idx, (key, cap_value) in enumerate(sorted(summary.capacitances.items())):
1017 f.write(f"C{idx + 1};{key.net1};{key.net2};{round(cap_value, 3)};\n")
1018 for idx, (key, res_value) in enumerate(sorted(summary.resistances.items())):
1019 f.write(f"R{idx + 1};{key.net1};{key.net2};;{round(res_value, 3)}\n")
1021 rule('kpex/2.5D extracted netlist (CSV format)')
1022 with open(netlist_csv_path, 'r') as f:
1023 for line in f.readlines():
1024 subproc(line[:-1]) # abusing subproc, simply want verbatim
1026 rule('Extracted netlist CSV')
1027 subproc(f"{netlist_csv_path}")
1029 if expanded_netlist_path is not None:
1030 rule('kpex/2.5D extracted netlist (SPICE format)')
1031 netlist_expander = RCX25NetlistExpander()
1032 expanded_netlist = netlist_expander.expand(
1033 extracted_netlist=pex_context.lvsdb.netlist(),
1034 top_cell_name=pex_context.annotated_top_cell.name,
1035 extraction_results=extraction_results,
1036 blackbox_devices=args.blackbox_devices
1037 )
1039 netlist_printer = self.create_netlist_printer(args, ExtractionEngine.K25D)
1040 netlist_printer.write(expanded_netlist, expanded_netlist_path)
1041 subproc(f"Wrote expanded netlist to: {expanded_netlist_path}")
1043 # FIXME: should this be already reduced?
1044 if args.output_spice_path:
1045 netlist_printer.write(expanded_netlist, args.output_spice_path)
1046 info(f"Copied expanded SPICE netlist to: {args.output_spice_path}")
1048 # NOTE: there was a KLayout bug that some of the categories were lost,
1049 # so that the marker browser could not load the report file
1050 try:
1051 report = rdb.ReportDatabase('')
1052 report.load(report_path) # try loading rdb
1053 except Exception as e:
1054 rule("Repair broken marker DB")
1055 warning(f"Detected KLayout bug: RDB can't be loaded due to exception {e}")
1056 repair_rdb(report_path)
1058 return extraction_results
1060 def setup_logging(self, args: argparse.Namespace):
1061 def register_log_file_handler(log_path: str,
1062 formatter: Optional[logging.Formatter]) -> logging.Handler:
1063 handler = logging.FileHandler(log_path)
1064 handler.setLevel(LogLevel.SUBPROCESS)
1065 if formatter:
1066 handler.setFormatter(formatter)
1067 register_additional_handler(handler)
1068 return handler
1070 def reregister_log_file_handler(handler: logging.Handler,
1071 log_path: str,
1072 formatter: Optional[logging.Formatter]):
1073 deregister_additional_handler(handler)
1074 handler.flush()
1075 handler.close()
1076 os.makedirs(args.output_dir_path, exist_ok=True)
1077 new_path = os.path.join(args.output_dir_path, os.path.basename(log_path))
1078 if os.path.exists(new_path):
1079 ctime = os.path.getctime(new_path)
1080 dt = datetime.fromtimestamp(ctime)
1081 timestamp = dt.strftime('%Y-%m-%d_%H-%M-%S')
1082 backup_path = f"{new_path[:-4]}_{timestamp}.bak.log"
1083 shutil.move(new_path, backup_path)
1084 log_path = shutil.move(log_path, new_path)
1085 register_log_file_handler(log_path, formatter)
1087 # setup preliminary logger
1088 cli_log_path_plain = os.path.join(args.output_dir_base_path, f"kpex_plain.log")
1089 cli_log_path_formatted = os.path.join(args.output_dir_base_path, f"kpex.log")
1090 formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s')
1091 file_handler_plain = register_log_file_handler(cli_log_path_plain, None)
1092 file_handler_formatted = register_log_file_handler(cli_log_path_formatted, formatter)
1093 try:
1094 self.validate_args(args)
1095 except ArgumentValidationError:
1096 if hasattr(args, 'output_dir_path'):
1097 reregister_log_file_handler(file_handler_plain, cli_log_path_plain, None)
1098 reregister_log_file_handler(file_handler_formatted, cli_log_path_formatted, formatter)
1099 sys.exit(1)
1100 reregister_log_file_handler(file_handler_plain, cli_log_path_plain, None)
1101 reregister_log_file_handler(file_handler_formatted, cli_log_path_formatted, formatter)
1103 set_log_level(args.log_level)
1105 @staticmethod
1106 def modification_date(filename: str) -> datetime:
1107 t = os.path.getmtime(filename)
1108 return datetime.fromtimestamp(t)
1110 def create_lvsdb(self, args: argparse.Namespace) -> kdb.LayoutVsSchematic:
1111 lvsdb = kdb.LayoutVsSchematic()
1113 match args.input_mode:
1114 case InputMode.LVSDB:
1115 lvsdb.read(args.lvsdb_path)
1116 case InputMode.GDS:
1117 lvs_log_path = os.path.join(args.output_dir_path, f"{args.effective_cell_name}_lvs.log")
1118 lvsdb_path = os.path.join(args.output_dir_path, f"{args.effective_cell_name}.lvsdb.gz")
1119 lvsdb_cache_path = os.path.join(args.cache_dir_path, args.pdk,
1120 os.path.splitroot(os.path.abspath(args.gds_path))[-1],
1121 f"{args.effective_cell_name}.lvsdb.gz")
1123 lvs_needed = True
1125 if args.cache_lvs:
1126 if not os.path.exists(lvsdb_cache_path):
1127 info(f"Cache miss: extracted LVSDB does not exist")
1128 subproc(lvsdb_cache_path)
1129 elif self.modification_date(lvsdb_cache_path) <= self.modification_date(args.gds_path):
1130 info(f"Cache miss: extracted LVSDB is older than the input GDS")
1131 subproc(lvsdb_cache_path)
1132 else:
1133 warning(f"Cache hit: Reusing cached LVSDB")
1134 subproc(lvsdb_cache_path)
1135 lvs_needed = False
1137 if lvs_needed:
1138 lvs_runner = LVSRunner()
1139 lvs_runner.run_klayout_lvs(exe_path=args.klayout_exe_path,
1140 lvs_script=args.lvs_script_path,
1141 gds_path=args.effective_gds_path,
1142 schematic_path=args.effective_schematic_path,
1143 log_path=lvs_log_path,
1144 lvsdb_path=lvsdb_path,
1145 verbose=args.klayout_lvs_verbose)
1146 if args.cache_lvs:
1147 cache_dir_path = os.path.dirname(lvsdb_cache_path)
1148 if not os.path.exists(cache_dir_path):
1149 os.makedirs(cache_dir_path, exist_ok=True)
1150 shutil.copy(lvsdb_path, lvsdb_cache_path)
1152 lvsdb.read(lvsdb_path)
1153 return lvsdb
1155 SUBCOMMANDS = ('extract', 'pex25d')
1156 LEGACY_SUBCOMMAND = 'extract'
1158 @classmethod
1159 def normalize_argv(cls, argv: List[str]) -> List[str]:
1160 """
1161 Accept the pre-subcommand, flat command line.
1163 Before the verb layer, kpex was invoked as
1164 ``kpex --pdk sky130A --gds foo.gds --2.5D``. Every such run still works
1165 and means ``kpex extract <same options>``; it just warns. The two forms
1166 cannot be confused, because a subcommand name never begins with '-' and
1167 every legacy option does — so the fallback needs no heuristics and can
1168 never mistake one for the other.
1170 This exists for the scripts, CI jobs and docs that already say
1171 ``kpex --gds ...``, including the test suite. It is meant to be removed
1172 once those have moved on.
1173 """
1174 tail = argv[1:]
1175 if not tail:
1176 return argv
1177 first = tail[0]
1178 if first in ('-h', '--help', '-v', '--version'):
1179 return argv
1180 if not first.startswith('-'):
1181 return argv # a subcommand, or a typo argparse will report better than we can
1182 if any(arg in cls.SUBCOMMANDS for arg in tail):
1183 # A subcommand is present, just not first — most likely a top-level
1184 # option that now belongs to the subcommand. Leave it alone and let
1185 # argparse say so, rather than silently rewriting it into something
1186 # that fails somewhere else.
1187 return argv
1188 warning(f"Calling {PROGRAM_NAME} without a subcommand is deprecated; "
1189 f"treating this run as '{PROGRAM_NAME} {cls.LEGACY_SUBCOMMAND} …'. "
1190 f"See '{PROGRAM_NAME} --help' for the available subcommands.")
1191 return [argv[0], cls.LEGACY_SUBCOMMAND] + tail
1193 @staticmethod
1194 def _writes_artifact_to_stdout(argv: List[str]) -> bool:
1195 """
1196 Whether this invocation sends a PEX25D artifact to stdout.
1198 Answered from the raw argv rather than from parsed arguments, because
1199 the redirect has to be in place before anything — argument validation
1200 included — has a chance to print.
1201 """
1202 return any(previous in ('--output_file', '--output_scene') and current == '-'
1203 for previous, current in zip(argv, argv[1:]))
1205 def main(self, argv: List[str]):
1206 env = Env.from_os_environ()
1207 argv = self.normalize_argv(argv)
1209 # A PEX25D artifact may be destined for stdout, and in that case stdout
1210 # belongs to the artifact alone — one stray log line and whatever is on
1211 # the other end of the pipe is parsing garbage. Redirecting sys.stdout to
1212 # stderr for the whole run is blunt but total: the rich console resolves
1213 # sys.stdout at write time, so every rule()/info()/subproc() follows,
1214 # including ones written by code that never considered being in a
1215 # pipeline. Artifact I/O uses sys.__stdout__ and is unaffected.
1216 writes_to_stdout = self._writes_artifact_to_stdout(argv)
1218 with contextlib.ExitStack() as stack:
1219 if writes_to_stdout:
1220 stack.enter_context(contextlib.redirect_stdout(sys.stderr))
1222 if not ({'-v', '--version', '-h', '--help'} & set(argv)):
1223 rule('Command line arguments')
1224 subproc(' '.join(map(shlex.quote, argv)))
1226 args = self.parse_args(arg_list=argv[1:], env=env)
1228 os.makedirs(args.output_dir_base_path, exist_ok=True)
1229 self.setup_logging(args)
1231 try:
1232 tech_info = TechInfo.from_json(args.tech_pbjson_path,
1233 dielectric_filter=args.dielectric_filter)
1234 except TechDefError as e:
1235 error(str(e))
1236 sys.exit(ExitCode.DIAGNOSTIC_ERRORS)
1238 match args.command:
1239 case 'pex25d':
1240 self.run_pex25d_generation(args=args, tech_info=tech_info)
1241 case _:
1242 self.run_extraction(args=args, tech_info=tech_info)
1244 def run_pex25d_generation(self,
1245 args: argparse.Namespace,
1246 tech_info: TechInfo):
1247 """
1248 The dump path: LVS for connectivity, then PEX25D out, and stop.
1250 Deliberately does not touch any engine. Everything downstream of the
1251 artifacts written here — validating them, re-encoding them, exporting
1252 them to a solver's native input — is the standalone ``pex25d`` tool's
1253 job, and needs neither a layout nor KLayout to do it.
1254 """
1255 from .klayout.pex25d_builder import BuildError, BuilderOptions, build_pex25d_file
1256 from .pex25d.codec import save_artifact
1257 from .pex25d.resolver import ResolveError, resolve
1258 from .pex25d.validator import validate
1260 report = DiagnosticsReport(warnings_are_errors=args.warnings_are_errors)
1262 rule('Prepare LVSDB')
1263 lvsdb = self.create_lvsdb(args)
1265 pex_context = KLayoutExtractionContext.prepare_extraction(
1266 top_cell=args.effective_cell_name,
1267 lvsdb=lvsdb,
1268 tech=tech_info,
1269 blackbox_devices=args.blackbox_devices)
1271 rule('Non-empty layers in LVS database')
1272 for gds_pair, layer_info in pex_context.extracted_layers.items():
1273 names = [l.lvs_layer_name for l in layer_info.source_layers]
1274 info(f"{gds_pair} -> ({' '.join(names)})")
1276 rule('PEX25D Generation')
1277 try:
1278 pex25d_file = build_pex25d_file(
1279 pex_context=pex_context,
1280 tech_info=tech_info,
1281 cell_name=args.effective_cell_name,
1282 options=BuilderOptions(
1283 grid_um=args.pex25d_grid,
1284 with_source_refs=args.pex25d_with_source_refs,
1285 dielectric_filter=args.dielectric_filter,
1286 domain_margin_um=args.pex25d_domain_margin))
1288 if args.pex25d_file_spec is not None:
1289 save_artifact(pex25d_file, args.pex25d_file_spec,
1290 comments=args.pex25d_comments)
1291 info(f"Wrote {args.pex25d_file_spec}")
1293 scene = None
1294 if args.pex25d_scene_spec is not None:
1295 # The geometric tier belongs to whichever of the two runs it
1296 # once: the validator when it is on, the resolver otherwise.
1297 scene = resolve(pex25d_file, report=report,
1298 strict=args.strict and not args.pex25d_validate)
1299 save_artifact(scene, args.pex25d_scene_spec,
1300 comments=args.pex25d_comments)
1301 info(f"Wrote {args.pex25d_scene_spec}")
1303 if args.pex25d_validate:
1304 validate(pex25d_file, report=report, strict=args.strict,
1305 scene=scene)
1307 except BuildError as e:
1308 error(f"Could not generate PEX25D: {e}")
1309 sys.exit(ExitCode.DIAGNOSTIC_ERRORS)
1310 except ResolveError as e:
1311 # The scene is not written: the diagnostics say why, and half a
1312 # scene is worse than none.
1313 error(str(e))
1314 report.render(args.diagnostics_format)
1315 sys.exit(ExitCode.DIAGNOSTIC_ERRORS)
1316 except NotImplementedError as e:
1317 error(f"Not implemented yet: {e}")
1318 sys.exit(ExitCode.NOT_IMPLEMENTED)
1320 writes_to_stdout = any(spec is not None and spec.is_stdio
1321 for spec in (args.pex25d_file_spec, args.pex25d_scene_spec))
1322 with diagnostics_stream(writes_to_stdout=writes_to_stdout) as stream:
1323 report.render(args.diagnostics_format, stream=stream)
1325 if report.exit_code != ExitCode.OK:
1326 sys.exit(report.exit_code)
1328 def run_extraction(self,
1329 args: argparse.Namespace,
1330 tech_info: TechInfo):
1331 if args.halo is not None:
1332 tech_info.tech.process_parasitics.side_halo = args.halo
1334 if args.run_magic:
1335 rule('MAGIC')
1336 self.run_magic_extraction(args)
1338 # no need to run LVS etc if only running magic engine
1339 if not (args.run_fastcap or args.run_fastercap or args.run_2_5D):
1340 return
1342 rule('Prepare LVSDB')
1343 lvsdb = self.create_lvsdb(args)
1345 pex_context = KLayoutExtractionContext.prepare_extraction(top_cell=args.effective_cell_name,
1346 lvsdb=lvsdb,
1347 tech=tech_info,
1348 blackbox_devices=args.blackbox_devices)
1349 rule('Non-empty layers in LVS database')
1350 for gds_pair, layer_info in pex_context.extracted_layers.items():
1351 names = [l.lvs_layer_name for l in layer_info.source_layers]
1352 info(f"{gds_pair} -> ({' '.join(names)})")
1354 gds_path = os.path.join(args.output_dir_path, f"{args.effective_cell_name}_l2n_extracted.oas")
1355 pex_context.annotated_layout.write(gds_path)
1357 gds_path = os.path.join(args.output_dir_path, f"{args.effective_cell_name}_l2n_internal.oas")
1358 pex_context.lvsdb.internal_layout().write(gds_path)
1360 def dump_layers(cell: str,
1361 layers: List[KLayoutExtractedLayerInfo],
1362 layout_dump_path: str):
1363 layout = kdb.Layout()
1364 layout.dbu = lvsdb.internal_layout().dbu
1366 top_cell = layout.create_cell(cell)
1367 for ulyr in layers:
1368 li = kdb.LayerInfo(*ulyr.gds_pair)
1369 li.name = ulyr.lvs_layer_name
1370 layer = layout.insert_layer(li)
1371 layout.insert(top_cell.cell_index(), layer, ulyr.region.dup())
1373 layout.write(layout_dump_path)
1375 if len(pex_context.unnamed_layers) >= 1:
1376 layout_dump_path = os.path.join(args.output_dir_path, f"{args.effective_cell_name}_unnamed_LVS_layers.gds.gz")
1377 dump_layers(cell=args.effective_cell_name,
1378 layers=pex_context.unnamed_layers,
1379 layout_dump_path=layout_dump_path)
1381 if len(pex_context.extracted_layers) >= 1:
1382 layout_dump_path = os.path.join(args.output_dir_path, f"{args.effective_cell_name}_nonempty_LVS_layers.gds.gz")
1383 nonempty_layers = [l \
1384 for layers in pex_context.extracted_layers.values() \
1385 for l in layers.source_layers]
1386 dump_layers(cell=args.effective_cell_name,
1387 layers=nonempty_layers,
1388 layout_dump_path=layout_dump_path)
1389 else:
1390 error("No extracted layers found")
1391 sys.exit(1)
1393 if args.run_fastcap or args.run_fastercap:
1394 lst_file = self.build_fastercap_input(args=args,
1395 pex_context=pex_context,
1396 tech_info=tech_info)
1397 if args.run_fastercap:
1398 self.run_fastercap_extraction(args=args,
1399 pex_context=pex_context,
1400 lst_file=lst_file)
1401 if args.run_fastcap:
1402 self.run_fastcap_extraction(args=args,
1403 pex_context=pex_context,
1404 lst_file=lst_file)
1406 if args.run_2_5D:
1407 rule("kpex/2.5D PEX Engine")
1408 report_path = os.path.join(args.output_dir_path, f"{args.effective_cell_name}_k25d_pex_report.rdb.gz")
1409 netlist_csv_path = os.path.abspath(os.path.join(args.output_dir_path,
1410 f"{args.effective_cell_name}_k25d_pex_netlist.csv"))
1411 netlist_spice_path = os.path.abspath(os.path.join(args.output_dir_path,
1412 f"{args.effective_cell_name}_k25d_pex_netlist.spice"))
1414 self._rcx25_extraction_results = self.run_kpex_2_5d_engine( # NOTE: store for test case
1415 args=args,
1416 pex_context=pex_context,
1417 tech_info=tech_info,
1418 report_path=report_path,
1419 netlist_csv_path=netlist_csv_path,
1420 expanded_netlist_path=netlist_spice_path
1421 )
1423 self._rcx25_extracted_csv_path = netlist_csv_path
1425 @property
1426 def rcx25_extraction_results(self) -> ExtractionResults:
1427 if not hasattr(self, '_rcx25_extraction_results'):
1428 raise Exception('rcx25_extraction_results is not initialized, was run_kpex_2_5d_engine called?')
1429 return self._rcx25_extraction_results
1431 @property
1432 def rcx25_extracted_csv_path(self) -> str:
1433 if not hasattr(self, '_rcx25_extracted_csv_path'):
1434 raise Exception('rcx25_extracted_csv_path is not initialized, was run_kpex_2_5d_engine called?')
1435 return self._rcx25_extracted_csv_path
1437 @property
1438 def fastercap_extracted_csv_path(self) -> str:
1439 if not hasattr(self, '_fastercap_extracted_csv_path'):
1440 raise Exception('fastercap_extracted_csv_path is not initialized, was run_fastercap_extraction called?')
1441 return self._fastercap_extracted_csv_path
1444if __name__ == "__main__":
1445 cli = KpexCLI()
1446 cli.main(sys.argv)