Skip to content

Commit 9a77294

Browse files
feat(tidy3d): FXC-4043-add-support-of-custom-drc-args-in-drc-runner
1 parent aa8e786 commit 9a77294

File tree

3 files changed

+185
-19
lines changed

3 files changed

+185
-19
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131
- Added multimode support to `WavePort` in the smatrix plugin, allowing multiple modes to be analyzed per port.
3232
- Added support for `.lydrc` files for design rule checking in the `klayout` plugin.
3333
- Added a Gaussian inverse design filter option with autograd gradients and complete padding mode coverage.
34+
- Added support for argument passing to DRC file when running checks with `DRCRunner.run(..., drc_args={key: value})` in klayout plugin.
3435

3536
### Breaking Changes
3637
- Edge singularity correction at PEC and lossy metal edges defaults to `True`.

tests/test_plugins/klayout/drc/test_drc.py

Lines changed: 129 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,30 +11,153 @@
1111

1212
import tidy3d as td
1313
from tidy3d.exceptions import FileError
14-
from tidy3d.plugins.klayout.drc.drc import DRCRunner
14+
from tidy3d.plugins.klayout.drc.drc import DRCConfig, DRCRunner, run_drc_on_gds
1515
from tidy3d.plugins.klayout.drc.results import DRCResults, parse_violation_value
1616
from tidy3d.plugins.klayout.util import check_installation
1717

1818
filepath = Path(os.path.dirname(os.path.abspath(__file__)))
19+
KLAYOUT_PLUGIN_PATH = "tidy3d.plugins.klayout"
20+
21+
22+
def _basic_drc_config_kwargs(tmp_path: Path) -> dict[str, Path | bool]:
23+
"""Return minimal kwargs needed to instantiate DRCConfig in tests."""
24+
25+
drc_runset = tmp_path / "test.drc"
26+
drc_runset.write_text('source($gdsfile)\nreport("DRC", $resultsfile)\n')
27+
gdsfile = tmp_path / "test.gds"
28+
gdsfile.write_text("")
29+
resultsfile = tmp_path / "results.lyrdb"
30+
return {
31+
"gdsfile": gdsfile,
32+
"drc_runset": drc_runset,
33+
"resultsfile": resultsfile,
34+
"verbose": False,
35+
}
1936

2037

2138
def test_check_klayout_not_installed(monkeypatch):
2239
"""check_installation raises when KLayout is not on PATH.
2340
2441
Use monkeypatch to simulate absence, avoiding reliance on CI environment.
2542
"""
26-
monkeypatch.setattr("tidy3d.plugins.klayout.util.which", lambda _cmd: None)
43+
monkeypatch.setattr(f"{KLAYOUT_PLUGIN_PATH}.util.which", lambda _cmd: None)
2744
with pytest.raises(RuntimeError):
2845
check_installation(raise_error=True)
2946

3047

3148
def test_check_klayout_installed(monkeypatch):
3249
"""check_installation returns a path and does not raise when present."""
3350
fake_path = "/usr/local/bin/klayout"
34-
monkeypatch.setattr("tidy3d.plugins.klayout.util.which", lambda _cmd: fake_path)
51+
monkeypatch.setattr(f"{KLAYOUT_PLUGIN_PATH}.util.which", lambda _cmd: fake_path)
3552
assert check_installation(raise_error=True) == fake_path
3653

3754

55+
def test_runner_passes_drc_args_to_config(monkeypatch, tmp_path):
56+
"""Ensure DRCRunner forwards drc_args into the generated DRCConfig."""
57+
58+
drc_runset = tmp_path / "test.drc"
59+
drc_runset.write_text('source($gdsfile)\nreport("DRC", $resultsfile)\n')
60+
gdsfile = tmp_path / "test.gds"
61+
gdsfile.write_text("")
62+
resultsfile = tmp_path / "results.lyrdb"
63+
captured_config = {}
64+
65+
def mock_run_drc_on_gds(config):
66+
captured_config["config"] = config
67+
return DRCResults.load(filepath / "drc_results.lyrdb")
68+
69+
monkeypatch.setattr(f"{KLAYOUT_PLUGIN_PATH}.drc.drc.run_drc_on_gds", mock_run_drc_on_gds)
70+
71+
runner = DRCRunner(drc_runset=drc_runset, verbose=False)
72+
user_args = {"foo": "bar", "baz": "1"}
73+
runner.run(source=gdsfile, resultsfile=resultsfile, drc_args=user_args)
74+
75+
assert captured_config["config"].drc_args == user_args
76+
77+
78+
def test_run_drc_on_gds_appends_custom_args(monkeypatch, tmp_path):
79+
"""run_drc_on_gds adds extra -rd pairs for drc_args."""
80+
81+
drc_runset = tmp_path / "test.drc"
82+
drc_runset.write_text('source($gdsfile)\nreport("DRC", $resultsfile)\n')
83+
gdsfile = tmp_path / "test.gds"
84+
gdsfile.write_text("")
85+
resultsfile = tmp_path / "results.lyrdb"
86+
87+
monkeypatch.setattr(f"{KLAYOUT_PLUGIN_PATH}.drc.drc.check_installation", lambda **_: None)
88+
89+
captured_cmd = {}
90+
91+
class DummyCompleted:
92+
def __init__(self):
93+
self.returncode = 0
94+
self.stdout = b""
95+
self.stderr = b""
96+
97+
def fake_run(cmd, capture_output):
98+
captured_cmd["cmd"] = cmd
99+
return DummyCompleted()
100+
101+
monkeypatch.setattr(f"{KLAYOUT_PLUGIN_PATH}.drc.drc.run", fake_run)
102+
monkeypatch.setattr(
103+
f"{KLAYOUT_PLUGIN_PATH}.drc.drc.DRCResults.load",
104+
lambda resultsfile: DRCResults(violations_by_category={}),
105+
)
106+
107+
config = DRCConfig(
108+
gdsfile=gdsfile,
109+
drc_runset=drc_runset,
110+
resultsfile=resultsfile,
111+
verbose=False,
112+
drc_args={"string_arg": "text", "numeric_value": 1},
113+
)
114+
115+
run_drc_on_gds(config)
116+
117+
expected_tail = ["-rd", "string_arg=text", "-rd", "numeric_value=1"]
118+
assert captured_cmd["cmd"][-len(expected_tail) :] == expected_tail
119+
120+
121+
def test_drc_config_args_require_mapping(tmp_path):
122+
"""drc_args must be a mapping and refuses other iterables."""
123+
124+
kwargs = _basic_drc_config_kwargs(tmp_path)
125+
with pytest.raises(pd.ValidationError):
126+
DRCConfig(**kwargs, drc_args=["not", "a", "mapping"])
127+
128+
129+
def test_drc_config_args_reject_reserved_keys(tmp_path):
130+
"""Reserved keys such as gdsfile cannot be overridden via drc_args."""
131+
132+
kwargs = _basic_drc_config_kwargs(tmp_path)
133+
with pytest.raises(pd.ValidationError):
134+
DRCConfig(**kwargs, drc_args={"gdsfile": "custom.gds"})
135+
136+
137+
def test_drc_config_args_stringify_values(tmp_path):
138+
"""Non-string keys and values are coerced to strings by the validator."""
139+
140+
kwargs = _basic_drc_config_kwargs(tmp_path)
141+
config = DRCConfig(**kwargs, drc_args={1: Path("foo"), "flag": True})
142+
143+
assert config.drc_args == {"1": "foo", "flag": "True"}
144+
145+
146+
def test_drc_config_args_unstringifiable_value(tmp_path):
147+
"""Non-stringifiable drc_args values should raise a ValidationError."""
148+
149+
class Unstringifiable:
150+
def __str__(self):
151+
raise RuntimeError("cannot stringify")
152+
153+
kwargs = _basic_drc_config_kwargs(tmp_path)
154+
155+
with pytest.raises(
156+
pd.ValidationError, match="Could not coerce keys and values of drc_args to strings."
157+
):
158+
DRCConfig(**kwargs, drc_args={"bad": Unstringifiable()})
159+
160+
38161
class TestDRCRunner:
39162
"""Test DRCRunner"""
40163

@@ -147,6 +270,7 @@ def run(
147270
source,
148271
td_object_gds_savefile,
149272
resultsfile,
273+
drc_args=None,
150274
**to_gds_file_kwargs,
151275
):
152276
"""Calls DRCRunner.run with dummy run_drc_on_gds()"""
@@ -155,13 +279,14 @@ def run(
155279
def mock_run_drc_on_gds(config):
156280
return DRCResults.load(filepath / "drc_results.lyrdb")
157281

158-
monkeypatch.setattr("tidy3d.plugins.klayout.drc.drc.run_drc_on_gds", mock_run_drc_on_gds)
282+
monkeypatch.setattr(f"{KLAYOUT_PLUGIN_PATH}.drc.drc.run_drc_on_gds", mock_run_drc_on_gds)
159283

160284
runner = DRCRunner(drc_runset=drc_runsetfile, verbose=verbose)
161285
return runner.run(
162286
source=source,
163287
td_object_gds_savefile=td_object_gds_savefile,
164288
resultsfile=resultsfile,
289+
drc_args=drc_args,
165290
**to_gds_file_kwargs,
166291
)
167292

tidy3d/plugins/klayout/drc/drc.py

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
from __future__ import annotations
44

55
import re
6+
from collections.abc import Mapping
67
from pathlib import Path
78
from subprocess import run
8-
from typing import Any, Union
9+
from typing import Any, Optional, Union
910

1011
import pydantic.v1 as pd
1112
from pydantic.v1 import validator
@@ -46,6 +47,11 @@ class DRCConfig(Tidy3dBaseModel):
4647
title="Verbose",
4748
description="Whether to print logging.",
4849
)
50+
drc_args: dict[str, str] = pd.Field(
51+
default_factory=dict,
52+
title="DRC File Arguments",
53+
description="Optional key/value pairs forwarded to KLayout as -rd <key>=<value> definitions.",
54+
)
4955

5056
@validator("gdsfile")
5157
def _validate_gdsfile_filetype(cls, v: pd.FilePath) -> pd.FilePath:
@@ -82,6 +88,34 @@ def _validate_drc_runset_format(cls, v: pd.FilePath) -> pd.FilePath:
8288
)
8389
return v
8490

91+
@validator("drc_args", pre=True)
92+
def _validate_drc_args_stringable(cls, v: Any) -> dict[str, str]:
93+
"""Coerce all keys and values in drc_args to strings."""
94+
if v is None:
95+
return {}
96+
if not isinstance(v, Mapping):
97+
raise ValidationError("drc_args must be a mapping of keys to values.")
98+
try:
99+
v = {str(k): str(v) for k, v in v.items()}
100+
except Exception as e:
101+
raise ValidationError("Could not coerce keys and values of drc_args to strings.") from e
102+
return v
103+
104+
@validator("drc_args")
105+
def _validate_drc_args_reserved(cls, v: dict[str, str]) -> dict[str, str]:
106+
"""Ensure user arguments do not override the reserved keys."""
107+
108+
reserved_keys = {"gdsfile", "resultsfile"}
109+
conflicts = reserved_keys.intersection(v)
110+
if conflicts:
111+
conflict_str = ", ".join(sorted(conflicts))
112+
raise ValidationError(
113+
f"Invalid DRC argument key(s) {conflict_str}: these names are reserved and automatically "
114+
"managed by Tidy3D."
115+
)
116+
117+
return v
118+
85119

86120
class DRCRunner(Tidy3dBaseModel):
87121
"""A class for running KLayout DRC. Can be used to run DRC on a Tidy3D object or a GDS file.
@@ -125,8 +159,9 @@ def run(
125159
source: Union[Geometry, Structure, Simulation, Path],
126160
td_object_gds_savefile: Path = DEFAULT_GDSFILE,
127161
resultsfile: Path = DEFAULT_RESULTSFILE,
162+
drc_args: Optional[dict[str, str]] = None,
128163
**to_gds_file_kwargs: Any,
129-
) -> None:
164+
) -> DRCResults:
130165
"""Runs KLayout's DRC on a GDS file or a Tidy3D object. The Tidy3D object can be a :class:`.Geometry`, :class:`.Structure`, or :class:`.Simulation`.
131166
132167
Parameters
@@ -137,6 +172,8 @@ def run(
137172
The path to save the Tidy3D object to. Defaults to ``"layout.gds"``.
138173
resultsfile : Path
139174
The path to save the KLayout DRC results file to. Defaults to ``"drc_results.lyrdb"``.
175+
drc_args : Optional[dict[str, str]] = None
176+
Additional key/value pairs passed through to KLayout as ``-rd key=value`` CLI arguments.
140177
**to_gds_file_kwargs
141178
Additional keyword arguments to pass to the Tidy3D object-specific ``to_gds_file()`` method.
142179
@@ -176,6 +213,7 @@ def run(
176213
drc_runset=self.drc_runset,
177214
resultsfile=resultsfile,
178215
verbose=self.verbose,
216+
drc_args={} if drc_args is None else drc_args,
179217
)
180218
return run_drc_on_gds(config=config)
181219

@@ -208,19 +246,21 @@ def run_drc_on_gds(config: DRCConfig) -> DRCResults:
208246
f"Running KLayout DRC on GDS file '{config.gdsfile}' with runset '{config.drc_runset}' and saving results to '{config.resultsfile}'..."
209247
)
210248
# run klayout DRC as a subprocess
211-
output = run(
212-
[
213-
"klayout",
214-
"-b",
215-
"-r",
216-
config.drc_runset,
217-
"-rd",
218-
f"gdsfile={config.gdsfile}",
219-
"-rd",
220-
f"resultsfile={config.resultsfile}",
221-
],
222-
capture_output=True,
223-
)
249+
cmd = [
250+
"klayout",
251+
"-b",
252+
"-r",
253+
config.drc_runset,
254+
"-rd",
255+
f"gdsfile={config.gdsfile}",
256+
"-rd",
257+
f"resultsfile={config.resultsfile}",
258+
]
259+
260+
for key, value in config.drc_args.items():
261+
cmd.extend(["-rd", f"{key}={value}"])
262+
263+
output = run(cmd, capture_output=True)
224264

225265
if output.returncode != 0:
226266
raise RuntimeError(f"KLayout DRC failed with error message: '{output.stderr}'.")

0 commit comments

Comments
 (0)