gdsdiff 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gdsdiff/__init__.py +148 -0
- gdsdiff/__main__.py +7 -0
- gdsdiff/_environment.py +12 -0
- gdsdiff/_version.py +34 -0
- gdsdiff/acceptance_evidence.py +530 -0
- gdsdiff/api.py +2955 -0
- gdsdiff/backends/__init__.py +26 -0
- gdsdiff/backends/base.py +77 -0
- gdsdiff/backends/boundary_loop_ctypes.py +191 -0
- gdsdiff/backends/cpu.py +20 -0
- gdsdiff/backends/cpu_ctypes.py +255 -0
- gdsdiff/backends/cuda.py +40 -0
- gdsdiff/backends/cuda_ctypes.py +1297 -0
- gdsdiff/backends/exact.py +424 -0
- gdsdiff/backends/geometry_ctypes.py +252 -0
- gdsdiff/backends/identity.py +53 -0
- gdsdiff/backends/metal.py +198 -0
- gdsdiff/backends/metal_ctypes.py +866 -0
- gdsdiff/backends/polygon_extract_ctypes.py +233 -0
- gdsdiff/backends/structural_ctypes.py +130 -0
- gdsdiff/boundary_loops.py +616 -0
- gdsdiff/cache.py +544 -0
- gdsdiff/canonicalize.py +176 -0
- gdsdiff/cli.py +352 -0
- gdsdiff/config.py +257 -0
- gdsdiff/cuda_setup.py +174 -0
- gdsdiff/design_history.py +65 -0
- gdsdiff/diagnostics.py +42 -0
- gdsdiff/diff_gds.py +66 -0
- gdsdiff/exact_diff_markdown.py +979 -0
- gdsdiff/exact_oracle.py +649 -0
- gdsdiff/extract.py +376 -0
- gdsdiff/gds_metadata.py +43 -0
- gdsdiff/geometry.py +112 -0
- gdsdiff/grid.py +143 -0
- gdsdiff/hierarchy.py +215 -0
- gdsdiff/hierarchy_extract.py +263 -0
- gdsdiff/inventory.py +153 -0
- gdsdiff/multiprocessing_support.py +39 -0
- gdsdiff/native.py +135 -0
- gdsdiff/native_cache.py +265 -0
- gdsdiff/native_src/cpu_prefilter.cpp +349 -0
- gdsdiff/native_src/gds_boundary_loops.cpp +505 -0
- gdsdiff/native_src/gds_geometry_scan.cpp +601 -0
- gdsdiff/native_src/gds_polygon_extract.cpp +594 -0
- gdsdiff/native_src/gds_structural_scan.cpp +335 -0
- gdsdiff/native_src/gdsdiff/CMakeLists.txt +74 -0
- gdsdiff/native_src/gdsdiff/core.cpp +191 -0
- gdsdiff/native_src/gdsdiff/core.hpp +96 -0
- gdsdiff/native_src/gdsdiff/cuda_assignment.cu +304 -0
- gdsdiff/native_src/gdsdiff/cuda_assignment.cuh +33 -0
- gdsdiff/native_src/gdsdiff/cuda_candidates.cu +133 -0
- gdsdiff/native_src/gdsdiff/cuda_candidates.cuh +17 -0
- gdsdiff/native_src/gdsdiff/cuda_ctypes.cu +4528 -0
- gdsdiff/native_src/gdsdiff/cuda_memory.cu +194 -0
- gdsdiff/native_src/gdsdiff/cuda_memory.cuh +34 -0
- gdsdiff/native_src/gdsdiff/metal_ctypes.mm +2791 -0
- gdsdiff/native_src/gdsdiff/python_bindings.cpp +21 -0
- gdsdiff/native_src/gdsdiff/test_core.cpp +93 -0
- gdsdiff/native_src/gdsdiff/test_cuda_assignment.cu +109 -0
- gdsdiff/native_src/gdsdiff/test_cuda_candidates.cu +94 -0
- gdsdiff/native_src/gdsdiff/test_cuda_memory.cu +97 -0
- gdsdiff/policy.py +305 -0
- gdsdiff/polygon_components.py +860 -0
- gdsdiff/profiles.py +152 -0
- gdsdiff/py.typed +1 -0
- gdsdiff/rect_coverage.py +384 -0
- gdsdiff/report.py +354 -0
- gdsdiff/schemas/gdsdiff_report-v1.schema.json +92 -0
- gdsdiff/semantics.py +75 -0
- gdsdiff/source_edge_diff.py +1120 -0
- gdsdiff/spatial.py +71 -0
- gdsdiff/structural_guard.py +161 -0
- gdsdiff/surplus_coverage.py +255 -0
- gdsdiff/synthetic_suite.py +1643 -0
- gdsdiff/templates.py +709 -0
- gdsdiff/tiling.py +153 -0
- gdsdiff/windowed_exact.py +270 -0
- gdsdiff/windows.py +184 -0
- gdsdiff-0.1.0.dist-info/METADATA +135 -0
- gdsdiff-0.1.0.dist-info/RECORD +85 -0
- gdsdiff-0.1.0.dist-info/WHEEL +5 -0
- gdsdiff-0.1.0.dist-info/entry_points.txt +4 -0
- gdsdiff-0.1.0.dist-info/licenses/LICENSE +674 -0
- gdsdiff-0.1.0.dist-info/top_level.txt +1 -0
gdsdiff/templates.py
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable
|
|
9
|
+
|
|
10
|
+
import gdstk
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from .config import LayerSpec, parse_layer_selector
|
|
14
|
+
from .exact_oracle import ExactOracleResult, format_twice_area, run_full_exact_oracle
|
|
15
|
+
from .extract import extract_gds_polygons
|
|
16
|
+
from .gds_metadata import decimal_from_number
|
|
17
|
+
from .geometry import (
|
|
18
|
+
LayerTable,
|
|
19
|
+
PolygonBuffer,
|
|
20
|
+
normalize_polygon_points,
|
|
21
|
+
polygon_bbox,
|
|
22
|
+
polygon_flags,
|
|
23
|
+
polygon_twice_area,
|
|
24
|
+
)
|
|
25
|
+
from .grid import snap_to_grid
|
|
26
|
+
from .hierarchy_extract import extract_hierarchy_window
|
|
27
|
+
from .spatial import BBox, bboxes_intersect
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
USER_BBOX = tuple[float, float, float, float]
|
|
31
|
+
TRANSFORM_TRANSLATE = "translate"
|
|
32
|
+
TRANSFORM_ROT90 = "rot90"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class TemplateExtractionResult:
|
|
37
|
+
output_gds: Path
|
|
38
|
+
metadata_path: Path | None
|
|
39
|
+
metadata: dict[str, object]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class TemplateFindResult:
|
|
44
|
+
output_dir: Path
|
|
45
|
+
matches_json: Path
|
|
46
|
+
matches_markdown: Path
|
|
47
|
+
result: dict[str, object]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class _GdsScale:
|
|
52
|
+
unit: Decimal
|
|
53
|
+
precision: Decimal
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def dbu_to_user(self) -> Decimal:
|
|
57
|
+
return self.precision / self.unit
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def user_to_dbu(self) -> Decimal:
|
|
61
|
+
return self.unit / self.precision
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class _PolygonRecord:
|
|
66
|
+
index: int
|
|
67
|
+
layer: LayerSpec
|
|
68
|
+
points: tuple[tuple[int, int], ...]
|
|
69
|
+
bbox: BBox
|
|
70
|
+
twice_area: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class _TransformVariant:
|
|
75
|
+
name: str
|
|
76
|
+
records: tuple[_PolygonRecord, ...]
|
|
77
|
+
bbox: BBox
|
|
78
|
+
anchor: _PolygonRecord
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class _MatchCandidate:
|
|
83
|
+
status: str
|
|
84
|
+
transform: str
|
|
85
|
+
origin_dbu: tuple[int, int]
|
|
86
|
+
bbox_dbu: BBox
|
|
87
|
+
polygon_count: int
|
|
88
|
+
target_polygon_count: int
|
|
89
|
+
layers: tuple[dict[str, object], ...]
|
|
90
|
+
exact: ExactOracleResult = field(repr=False)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def extract_template(
|
|
94
|
+
source_gds: str | Path,
|
|
95
|
+
*,
|
|
96
|
+
output_gds: str | Path,
|
|
97
|
+
metadata_path: str | Path | None = None,
|
|
98
|
+
top_name: str | None = None,
|
|
99
|
+
layers: str | LayerSpec | Iterable[str | tuple[int, int] | LayerSpec] = "all",
|
|
100
|
+
bbox_user: USER_BBOX,
|
|
101
|
+
template_top: str = "TEMPLATE",
|
|
102
|
+
) -> TemplateExtractionResult:
|
|
103
|
+
"""Extract a bbox-selected template from a GDS into a standalone GDS."""
|
|
104
|
+
|
|
105
|
+
source_path = Path(source_gds)
|
|
106
|
+
output_path = Path(output_gds)
|
|
107
|
+
metadata = Path(metadata_path) if metadata_path is not None else None
|
|
108
|
+
|
|
109
|
+
lib = gdstk.read_gds(str(source_path))
|
|
110
|
+
scale = _scale_from_library(lib)
|
|
111
|
+
bbox_dbu = _user_bbox_to_dbu(bbox_user, scale)
|
|
112
|
+
extracted = extract_hierarchy_window(
|
|
113
|
+
lib,
|
|
114
|
+
window_bbox=bbox_dbu,
|
|
115
|
+
top_name=top_name,
|
|
116
|
+
layers=layers,
|
|
117
|
+
meters_per_dbu=scale.precision,
|
|
118
|
+
)
|
|
119
|
+
normalized = _translate_buffer(extracted.buffer, -bbox_dbu[0], -bbox_dbu[1])
|
|
120
|
+
_write_buffer_gds(normalized, output_path, cell_name=template_top, scale=scale)
|
|
121
|
+
|
|
122
|
+
payload = _template_metadata(
|
|
123
|
+
source_path=source_path,
|
|
124
|
+
output_path=output_path,
|
|
125
|
+
source_top=extracted.top_name,
|
|
126
|
+
template_top=template_top,
|
|
127
|
+
layers=layers,
|
|
128
|
+
source_bbox_user=bbox_user,
|
|
129
|
+
source_bbox_dbu=bbox_dbu,
|
|
130
|
+
normalized=normalized,
|
|
131
|
+
scale=scale,
|
|
132
|
+
extraction_stats=extracted.stats.to_json(),
|
|
133
|
+
)
|
|
134
|
+
if metadata is not None:
|
|
135
|
+
metadata.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
metadata.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
137
|
+
return TemplateExtractionResult(output_gds=output_path, metadata_path=metadata, metadata=payload)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def find_template(
|
|
141
|
+
template_gds: str | Path,
|
|
142
|
+
target_gds: str | Path,
|
|
143
|
+
*,
|
|
144
|
+
output_dir: str | Path,
|
|
145
|
+
template_top: str = "TEMPLATE",
|
|
146
|
+
target_top: str | None = None,
|
|
147
|
+
layers: str | LayerSpec | Iterable[str | tuple[int, int] | LayerSpec] = "all",
|
|
148
|
+
transforms: Iterable[str] = (TRANSFORM_TRANSLATE,),
|
|
149
|
+
allow_mirror: bool = False,
|
|
150
|
+
) -> TemplateFindResult:
|
|
151
|
+
"""Find exact normalized copies of a template inside a target GDS."""
|
|
152
|
+
|
|
153
|
+
template_path = Path(template_gds)
|
|
154
|
+
target_path = Path(target_gds)
|
|
155
|
+
output = Path(output_dir)
|
|
156
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
|
|
158
|
+
template_extraction = extract_gds_polygons(template_path, top_name=template_top, layers=layers)
|
|
159
|
+
target_extraction = extract_gds_polygons(target_path, top_name=target_top, layers=layers)
|
|
160
|
+
template_records = _records_from_buffer(template_extraction.buffer)
|
|
161
|
+
target_records = _records_from_buffer(target_extraction.buffer)
|
|
162
|
+
if not template_records:
|
|
163
|
+
raise ValueError("template contains no selected polygons")
|
|
164
|
+
|
|
165
|
+
matrices = _transform_matrices(tuple(transforms), allow_mirror=allow_mirror)
|
|
166
|
+
target_by_anchor = _target_anchor_index(target_records)
|
|
167
|
+
candidates: list[_MatchCandidate] = []
|
|
168
|
+
seen: set[tuple[BBox, str]] = set()
|
|
169
|
+
|
|
170
|
+
for transform_name, matrix in matrices:
|
|
171
|
+
variant = _transform_template_records(template_records, transform_name, matrix)
|
|
172
|
+
anchor_key = _anchor_key(variant.anchor)
|
|
173
|
+
for target_anchor in target_by_anchor.get(anchor_key, ()):
|
|
174
|
+
origin = (
|
|
175
|
+
target_anchor.bbox[0] - variant.anchor.bbox[0],
|
|
176
|
+
target_anchor.bbox[1] - variant.anchor.bbox[1],
|
|
177
|
+
)
|
|
178
|
+
candidate_bbox = _shift_bbox(variant.bbox, origin)
|
|
179
|
+
seen_key = (candidate_bbox, transform_name)
|
|
180
|
+
if seen_key in seen:
|
|
181
|
+
continue
|
|
182
|
+
seen.add(seen_key)
|
|
183
|
+
placed_template = _shift_records(variant.records, origin)
|
|
184
|
+
target_subset = tuple(record for record in target_records if bboxes_intersect(record.bbox, candidate_bbox))
|
|
185
|
+
template_buffer = _buffer_from_records(placed_template)
|
|
186
|
+
target_buffer = _buffer_from_records(target_subset)
|
|
187
|
+
exact = run_full_exact_oracle(template_buffer, target_buffer, workers=1)
|
|
188
|
+
candidates.append(
|
|
189
|
+
_MatchCandidate(
|
|
190
|
+
status="identical" if exact.equivalent else "differences-found",
|
|
191
|
+
transform=transform_name,
|
|
192
|
+
origin_dbu=origin,
|
|
193
|
+
bbox_dbu=candidate_bbox,
|
|
194
|
+
polygon_count=len(placed_template),
|
|
195
|
+
target_polygon_count=len(target_subset),
|
|
196
|
+
layers=_candidate_layer_summary(template_buffer, target_buffer, exact),
|
|
197
|
+
exact=exact,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
candidates.sort(key=lambda item: (item.status != "identical", item.bbox_dbu, item.transform))
|
|
202
|
+
result = _find_result_payload(
|
|
203
|
+
template_path=template_path,
|
|
204
|
+
target_path=target_path,
|
|
205
|
+
template_top=template_extraction.top_name,
|
|
206
|
+
target_top=target_extraction.top_name,
|
|
207
|
+
layers=layers,
|
|
208
|
+
transforms=tuple(name for name, _matrix in matrices),
|
|
209
|
+
allow_mirror=allow_mirror,
|
|
210
|
+
template_buffer=template_extraction.buffer,
|
|
211
|
+
target_buffer=target_extraction.buffer,
|
|
212
|
+
candidates=tuple(candidates),
|
|
213
|
+
)
|
|
214
|
+
matches_json = output / "matches.json"
|
|
215
|
+
matches_markdown = output / "matches.md"
|
|
216
|
+
matches_json.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
217
|
+
matches_markdown.write_text(_render_matches_markdown(result), encoding="utf-8")
|
|
218
|
+
return TemplateFindResult(
|
|
219
|
+
output_dir=output,
|
|
220
|
+
matches_json=matches_json,
|
|
221
|
+
matches_markdown=matches_markdown,
|
|
222
|
+
result=result,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _scale_from_library(lib: gdstk.Library) -> _GdsScale:
|
|
227
|
+
return _GdsScale(unit=decimal_from_number(lib.unit), precision=decimal_from_number(lib.precision))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _user_bbox_to_dbu(bbox: USER_BBOX, scale: _GdsScale) -> BBox:
|
|
231
|
+
if len(bbox) != 4:
|
|
232
|
+
raise ValueError("bbox must contain x0,y0,x1,y1")
|
|
233
|
+
x0, y0, x1, y1 = (decimal_from_number(value) for value in bbox)
|
|
234
|
+
if x0 >= x1 or y0 >= y1:
|
|
235
|
+
raise ValueError("bbox must satisfy x0 < x1 and y0 < y1")
|
|
236
|
+
return (
|
|
237
|
+
snap_to_grid(x0 * scale.unit, scale.precision),
|
|
238
|
+
snap_to_grid(y0 * scale.unit, scale.precision),
|
|
239
|
+
snap_to_grid(x1 * scale.unit, scale.precision),
|
|
240
|
+
snap_to_grid(y1 * scale.unit, scale.precision),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _bbox_dbu_to_user(bbox: BBox, scale: _GdsScale) -> list[float]:
|
|
245
|
+
factor = scale.dbu_to_user
|
|
246
|
+
return [float(Decimal(value) * factor) for value in bbox]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _points_to_user(points: tuple[tuple[int, int], ...], scale: _GdsScale) -> list[tuple[float, float]]:
|
|
250
|
+
factor = scale.dbu_to_user
|
|
251
|
+
return [(float(Decimal(x) * factor), float(Decimal(y) * factor)) for x, y in points]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _translate_buffer(buffer: PolygonBuffer, dx: int, dy: int) -> PolygonBuffer:
|
|
255
|
+
vertices = buffer.vertices_xy.copy()
|
|
256
|
+
if len(vertices):
|
|
257
|
+
vertices[:, 0] += dx
|
|
258
|
+
vertices[:, 1] += dy
|
|
259
|
+
bboxes = buffer.bboxes.copy()
|
|
260
|
+
if len(bboxes):
|
|
261
|
+
bboxes[:, 0] += dx
|
|
262
|
+
bboxes[:, 2] += dx
|
|
263
|
+
bboxes[:, 1] += dy
|
|
264
|
+
bboxes[:, 3] += dy
|
|
265
|
+
return PolygonBuffer(
|
|
266
|
+
vertices_xy=vertices,
|
|
267
|
+
polygon_offsets=buffer.polygon_offsets.copy(),
|
|
268
|
+
layer_ids=buffer.layer_ids.copy(),
|
|
269
|
+
bboxes=bboxes,
|
|
270
|
+
flags=buffer.flags.copy(),
|
|
271
|
+
source_ids=buffer.source_ids.copy(),
|
|
272
|
+
layer_table=buffer.layer_table,
|
|
273
|
+
polygon_hashes=buffer.polygon_hashes.copy() if buffer.polygon_hashes is not None else None,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _write_buffer_gds(buffer: PolygonBuffer, path: Path, *, cell_name: str, scale: _GdsScale) -> None:
|
|
278
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
lib = gdstk.Library(unit=float(scale.unit), precision=float(scale.precision))
|
|
280
|
+
cell = lib.new_cell(cell_name)
|
|
281
|
+
for record in _records_from_buffer(buffer):
|
|
282
|
+
cell.add(
|
|
283
|
+
gdstk.Polygon(
|
|
284
|
+
_points_to_user(record.points, scale),
|
|
285
|
+
layer=record.layer.layer,
|
|
286
|
+
datatype=record.layer.datatype,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
lib.write_gds(str(path))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _records_from_buffer(buffer: PolygonBuffer) -> tuple[_PolygonRecord, ...]:
|
|
293
|
+
records = []
|
|
294
|
+
for index in range(buffer.polygon_count):
|
|
295
|
+
layer = buffer.layer_table.layers[int(buffer.layer_ids[index])]
|
|
296
|
+
points = tuple((int(x), int(y)) for x, y in buffer.polygon_vertices(index))
|
|
297
|
+
records.append(
|
|
298
|
+
_PolygonRecord(
|
|
299
|
+
index=index,
|
|
300
|
+
layer=layer,
|
|
301
|
+
points=points,
|
|
302
|
+
bbox=tuple(int(value) for value in buffer.bboxes[index]),
|
|
303
|
+
twice_area=polygon_twice_area(points),
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
return tuple(records)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _buffer_from_records(records: Iterable[_PolygonRecord]) -> PolygonBuffer:
|
|
310
|
+
record_tuple = tuple(records)
|
|
311
|
+
layers = tuple(sorted({record.layer for record in record_tuple}))
|
|
312
|
+
layer_table = LayerTable(layers)
|
|
313
|
+
layer_index = layer_table.index_by_layer
|
|
314
|
+
vertices: list[tuple[int, int]] = []
|
|
315
|
+
offsets = [0]
|
|
316
|
+
layer_ids = []
|
|
317
|
+
bboxes = []
|
|
318
|
+
flags = []
|
|
319
|
+
source_ids = []
|
|
320
|
+
for source_id, record in enumerate(record_tuple):
|
|
321
|
+
vertices.extend(record.points)
|
|
322
|
+
offsets.append(len(vertices))
|
|
323
|
+
layer_ids.append(layer_index[record.layer])
|
|
324
|
+
bboxes.append(record.bbox)
|
|
325
|
+
flags.append(polygon_flags(record.points))
|
|
326
|
+
source_ids.append(source_id)
|
|
327
|
+
return PolygonBuffer(
|
|
328
|
+
vertices_xy=np.asarray(vertices, dtype=np.int64).reshape((-1, 2)),
|
|
329
|
+
polygon_offsets=np.asarray(offsets, dtype=np.uint64),
|
|
330
|
+
layer_ids=np.asarray(layer_ids, dtype=np.uint32),
|
|
331
|
+
bboxes=np.asarray(bboxes, dtype=np.int64).reshape((len(record_tuple), 4)),
|
|
332
|
+
flags=np.asarray(flags, dtype=np.uint32),
|
|
333
|
+
source_ids=np.asarray(source_ids, dtype=np.uint64),
|
|
334
|
+
layer_table=layer_table,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _template_metadata(
|
|
339
|
+
*,
|
|
340
|
+
source_path: Path,
|
|
341
|
+
output_path: Path,
|
|
342
|
+
source_top: str,
|
|
343
|
+
template_top: str,
|
|
344
|
+
layers,
|
|
345
|
+
source_bbox_user: USER_BBOX,
|
|
346
|
+
source_bbox_dbu: BBox,
|
|
347
|
+
normalized: PolygonBuffer,
|
|
348
|
+
scale: _GdsScale,
|
|
349
|
+
extraction_stats: dict[str, object],
|
|
350
|
+
) -> dict[str, object]:
|
|
351
|
+
bbox = _buffer_bbox(normalized)
|
|
352
|
+
return {
|
|
353
|
+
"mode": "gdsdiff-template-extract-v1",
|
|
354
|
+
"source": {
|
|
355
|
+
"path": str(source_path),
|
|
356
|
+
"top": source_top,
|
|
357
|
+
"bbox_user": list(source_bbox_user),
|
|
358
|
+
"bbox_dbu": list(source_bbox_dbu),
|
|
359
|
+
"layers": _layers_json(layers),
|
|
360
|
+
"selection_mode": "intersects",
|
|
361
|
+
},
|
|
362
|
+
"template": {
|
|
363
|
+
"path": str(output_path),
|
|
364
|
+
"top": template_top,
|
|
365
|
+
"normalization_origin_dbu": [source_bbox_dbu[0], source_bbox_dbu[1]],
|
|
366
|
+
"bbox_dbu": list(bbox) if bbox is not None else None,
|
|
367
|
+
"bbox_user": _bbox_dbu_to_user(bbox, scale) if bbox is not None else None,
|
|
368
|
+
"polygon_count": normalized.polygon_count,
|
|
369
|
+
"vertex_count": normalized.vertex_count,
|
|
370
|
+
"layer_summary": _buffer_layer_summary(normalized, scale=scale),
|
|
371
|
+
"signature_sha256": _buffer_signature(normalized),
|
|
372
|
+
},
|
|
373
|
+
"gds": {"unit": str(scale.unit), "precision": str(scale.precision)},
|
|
374
|
+
"extraction": extraction_stats,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _layers_json(layers) -> object:
|
|
379
|
+
parsed = parse_layer_selector(layers)
|
|
380
|
+
if parsed == "all":
|
|
381
|
+
return "all"
|
|
382
|
+
return [layer.to_json() for layer in parsed]
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _buffer_bbox(buffer: PolygonBuffer) -> BBox | None:
|
|
386
|
+
if buffer.polygon_count == 0:
|
|
387
|
+
return None
|
|
388
|
+
return (
|
|
389
|
+
int(buffer.bboxes[:, 0].min()),
|
|
390
|
+
int(buffer.bboxes[:, 1].min()),
|
|
391
|
+
int(buffer.bboxes[:, 2].max()),
|
|
392
|
+
int(buffer.bboxes[:, 3].max()),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _buffer_layer_summary(buffer: PolygonBuffer, *, scale: _GdsScale | None = None) -> list[dict[str, object]]:
|
|
397
|
+
summary: dict[LayerSpec, dict[str, object]] = {}
|
|
398
|
+
for record in _records_from_buffer(buffer):
|
|
399
|
+
item = summary.setdefault(
|
|
400
|
+
record.layer,
|
|
401
|
+
{
|
|
402
|
+
"layer": record.layer.layer,
|
|
403
|
+
"datatype": record.layer.datatype,
|
|
404
|
+
"polygon_count": 0,
|
|
405
|
+
"twice_area_dbu2": 0,
|
|
406
|
+
"bbox_dbu": None,
|
|
407
|
+
},
|
|
408
|
+
)
|
|
409
|
+
item["polygon_count"] = int(item["polygon_count"]) + 1
|
|
410
|
+
item["twice_area_dbu2"] = int(item["twice_area_dbu2"]) + record.twice_area
|
|
411
|
+
item["bbox_dbu"] = list(_bbox_union((tuple(item["bbox_dbu"]) if item["bbox_dbu"] is not None else None, record.bbox)))
|
|
412
|
+
out = []
|
|
413
|
+
for layer in sorted(summary):
|
|
414
|
+
item = dict(summary[layer])
|
|
415
|
+
item["area_dbu2"] = format_twice_area(int(item.pop("twice_area_dbu2")))
|
|
416
|
+
if scale is not None:
|
|
417
|
+
factor = scale.dbu_to_user * scale.dbu_to_user
|
|
418
|
+
item["area_user2"] = float((Decimal(int(summary[layer]["twice_area_dbu2"])) / Decimal(2)) * factor)
|
|
419
|
+
out.append(item)
|
|
420
|
+
return out
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _bbox_union(bboxes: Iterable[BBox | None]) -> BBox:
|
|
424
|
+
clean = tuple(bbox for bbox in bboxes if bbox is not None)
|
|
425
|
+
if not clean:
|
|
426
|
+
raise ValueError("bbox union requires at least one bbox")
|
|
427
|
+
return (
|
|
428
|
+
min(bbox[0] for bbox in clean),
|
|
429
|
+
min(bbox[1] for bbox in clean),
|
|
430
|
+
max(bbox[2] for bbox in clean),
|
|
431
|
+
max(bbox[3] for bbox in clean),
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _buffer_signature(buffer: PolygonBuffer) -> str:
|
|
436
|
+
records = []
|
|
437
|
+
for record in _records_from_buffer(buffer):
|
|
438
|
+
records.append((record.layer.layer, record.layer.datatype, record.points))
|
|
439
|
+
payload = repr(tuple(sorted(records))).encode("utf-8")
|
|
440
|
+
return hashlib.sha256(payload).hexdigest()
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _transform_matrices(transforms: tuple[str, ...], *, allow_mirror: bool) -> tuple[tuple[str, tuple[int, int, int, int]], ...]:
|
|
444
|
+
normalized = {item.strip().lower() for item in transforms}
|
|
445
|
+
if not normalized or TRANSFORM_TRANSLATE in normalized:
|
|
446
|
+
matrices = [("translate", (1, 0, 0, 1))]
|
|
447
|
+
else:
|
|
448
|
+
matrices = []
|
|
449
|
+
if TRANSFORM_ROT90 in normalized:
|
|
450
|
+
matrices.extend(
|
|
451
|
+
[
|
|
452
|
+
("rot90", (0, -1, 1, 0)),
|
|
453
|
+
("rot180", (-1, 0, 0, -1)),
|
|
454
|
+
("rot270", (0, 1, -1, 0)),
|
|
455
|
+
]
|
|
456
|
+
)
|
|
457
|
+
unknown = normalized - {TRANSFORM_TRANSLATE, TRANSFORM_ROT90}
|
|
458
|
+
if unknown:
|
|
459
|
+
raise ValueError(f"unknown template transform(s): {', '.join(sorted(unknown))}")
|
|
460
|
+
if allow_mirror:
|
|
461
|
+
base = tuple(matrices)
|
|
462
|
+
matrices.extend((f"{name}+mirror-x", (a, -b, c, -d)) for name, (a, b, c, d) in base)
|
|
463
|
+
dedup: dict[tuple[int, int, int, int], str] = {}
|
|
464
|
+
for name, matrix in matrices:
|
|
465
|
+
dedup.setdefault(matrix, name)
|
|
466
|
+
return tuple((name, matrix) for matrix, name in dedup.items())
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _transform_template_records(
|
|
470
|
+
records: tuple[_PolygonRecord, ...],
|
|
471
|
+
name: str,
|
|
472
|
+
matrix: tuple[int, int, int, int],
|
|
473
|
+
) -> _TransformVariant:
|
|
474
|
+
transformed_records = []
|
|
475
|
+
a, b, c, d = matrix
|
|
476
|
+
for record in records:
|
|
477
|
+
points = normalize_polygon_points([(a * x + b * y, c * x + d * y) for x, y in record.points])
|
|
478
|
+
transformed_records.append(
|
|
479
|
+
_PolygonRecord(
|
|
480
|
+
index=record.index,
|
|
481
|
+
layer=record.layer,
|
|
482
|
+
points=points,
|
|
483
|
+
bbox=polygon_bbox(points),
|
|
484
|
+
twice_area=polygon_twice_area(points),
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
bbox = _bbox_union(tuple(record.bbox for record in transformed_records))
|
|
488
|
+
normalized = tuple(
|
|
489
|
+
_PolygonRecord(
|
|
490
|
+
index=record.index,
|
|
491
|
+
layer=record.layer,
|
|
492
|
+
points=tuple((x - bbox[0], y - bbox[1]) for x, y in record.points),
|
|
493
|
+
bbox=(record.bbox[0] - bbox[0], record.bbox[1] - bbox[1], record.bbox[2] - bbox[0], record.bbox[3] - bbox[1]),
|
|
494
|
+
twice_area=record.twice_area,
|
|
495
|
+
)
|
|
496
|
+
for record in transformed_records
|
|
497
|
+
)
|
|
498
|
+
variant_bbox = _bbox_union(tuple(record.bbox for record in normalized))
|
|
499
|
+
anchor = _select_anchor(normalized)
|
|
500
|
+
return _TransformVariant(name=name, records=normalized, bbox=variant_bbox, anchor=anchor)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _select_anchor(records: tuple[_PolygonRecord, ...]) -> _PolygonRecord:
|
|
504
|
+
return min(records, key=lambda record: (-record.twice_area, record.layer, record.bbox, record.points))
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _target_anchor_index(records: tuple[_PolygonRecord, ...]) -> dict[tuple[object, ...], list[_PolygonRecord]]:
|
|
508
|
+
out: dict[tuple[object, ...], list[_PolygonRecord]] = {}
|
|
509
|
+
for record in records:
|
|
510
|
+
out.setdefault(_anchor_key(record), []).append(record)
|
|
511
|
+
return out
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _anchor_key(record: _PolygonRecord) -> tuple[object, ...]:
|
|
515
|
+
x0, y0, x1, y1 = record.bbox
|
|
516
|
+
normalized = tuple((x - x0, y - y0) for x, y in record.points)
|
|
517
|
+
return (
|
|
518
|
+
record.layer,
|
|
519
|
+
x1 - x0,
|
|
520
|
+
y1 - y0,
|
|
521
|
+
record.twice_area,
|
|
522
|
+
len(record.points),
|
|
523
|
+
_canonical_points(normalized),
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _canonical_points(points: tuple[tuple[int, int], ...]) -> tuple[tuple[int, int], ...]:
|
|
528
|
+
if not points:
|
|
529
|
+
return points
|
|
530
|
+
forward = _min_cyclic_rotation(points)
|
|
531
|
+
reverse = _min_cyclic_rotation(tuple(reversed(points)))
|
|
532
|
+
return min(forward, reverse)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _min_cyclic_rotation(points: tuple[tuple[int, int], ...]) -> tuple[tuple[int, int], ...]:
|
|
536
|
+
return min(points[index:] + points[:index] for index in range(len(points)))
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _shift_records(records: tuple[_PolygonRecord, ...], origin: tuple[int, int]) -> tuple[_PolygonRecord, ...]:
|
|
540
|
+
dx, dy = origin
|
|
541
|
+
return tuple(
|
|
542
|
+
_PolygonRecord(
|
|
543
|
+
index=record.index,
|
|
544
|
+
layer=record.layer,
|
|
545
|
+
points=tuple((x + dx, y + dy) for x, y in record.points),
|
|
546
|
+
bbox=_shift_bbox(record.bbox, origin),
|
|
547
|
+
twice_area=record.twice_area,
|
|
548
|
+
)
|
|
549
|
+
for record in records
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _shift_bbox(bbox: BBox, origin: tuple[int, int]) -> BBox:
|
|
554
|
+
dx, dy = origin
|
|
555
|
+
return bbox[0] + dx, bbox[1] + dy, bbox[2] + dx, bbox[3] + dy
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _candidate_layer_summary(
|
|
559
|
+
template_buffer: PolygonBuffer,
|
|
560
|
+
target_buffer: PolygonBuffer,
|
|
561
|
+
exact: ExactOracleResult,
|
|
562
|
+
) -> tuple[dict[str, object], ...]:
|
|
563
|
+
template = _raw_layer_metrics(template_buffer)
|
|
564
|
+
target = _raw_layer_metrics(target_buffer)
|
|
565
|
+
exact_by_layer = {layer.layer: layer for layer in exact.layers}
|
|
566
|
+
rows = []
|
|
567
|
+
for layer in sorted(set(template) | set(target) | set(exact_by_layer)):
|
|
568
|
+
template_metrics = template.get(layer, {"polygon_count": 0, "twice_area": 0})
|
|
569
|
+
target_metrics = target.get(layer, {"polygon_count": 0, "twice_area": 0})
|
|
570
|
+
diff = exact_by_layer.get(layer)
|
|
571
|
+
rows.append(
|
|
572
|
+
{
|
|
573
|
+
"layer": layer.layer,
|
|
574
|
+
"datatype": layer.datatype,
|
|
575
|
+
"template_polygon_count": template_metrics["polygon_count"],
|
|
576
|
+
"target_polygon_count": target_metrics["polygon_count"],
|
|
577
|
+
"template_area_dbu2": format_twice_area(int(template_metrics["twice_area"])),
|
|
578
|
+
"target_area_dbu2": format_twice_area(int(target_metrics["twice_area"])),
|
|
579
|
+
"template_only_area_dbu2": format_twice_area(diff.old_minus_new_twice_area if diff is not None else 0),
|
|
580
|
+
"target_only_area_dbu2": format_twice_area(diff.new_minus_old_twice_area if diff is not None else 0),
|
|
581
|
+
"xor_area_dbu2": format_twice_area(diff.xor_twice_area if diff is not None else 0),
|
|
582
|
+
"template_only_count": len(diff.old_minus_new) if diff is not None else 0,
|
|
583
|
+
"target_only_count": len(diff.new_minus_old) if diff is not None else 0,
|
|
584
|
+
}
|
|
585
|
+
)
|
|
586
|
+
return tuple(rows)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _raw_layer_metrics(buffer: PolygonBuffer) -> dict[LayerSpec, dict[str, int]]:
|
|
590
|
+
metrics: dict[LayerSpec, dict[str, int]] = {}
|
|
591
|
+
for record in _records_from_buffer(buffer):
|
|
592
|
+
item = metrics.setdefault(record.layer, {"polygon_count": 0, "twice_area": 0})
|
|
593
|
+
item["polygon_count"] += 1
|
|
594
|
+
item["twice_area"] += record.twice_area
|
|
595
|
+
return metrics
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _find_result_payload(
|
|
599
|
+
*,
|
|
600
|
+
template_path: Path,
|
|
601
|
+
target_path: Path,
|
|
602
|
+
template_top: str,
|
|
603
|
+
target_top: str,
|
|
604
|
+
layers,
|
|
605
|
+
transforms: tuple[str, ...],
|
|
606
|
+
allow_mirror: bool,
|
|
607
|
+
template_buffer: PolygonBuffer,
|
|
608
|
+
target_buffer: PolygonBuffer,
|
|
609
|
+
candidates: tuple[_MatchCandidate, ...],
|
|
610
|
+
) -> dict[str, object]:
|
|
611
|
+
return {
|
|
612
|
+
"mode": "gdsdiff-template-find-v1",
|
|
613
|
+
"template": {
|
|
614
|
+
"path": str(template_path),
|
|
615
|
+
"top": template_top,
|
|
616
|
+
"polygon_count": template_buffer.polygon_count,
|
|
617
|
+
"signature_sha256": _buffer_signature(template_buffer),
|
|
618
|
+
},
|
|
619
|
+
"target": {
|
|
620
|
+
"path": str(target_path),
|
|
621
|
+
"top": target_top,
|
|
622
|
+
"polygon_count": target_buffer.polygon_count,
|
|
623
|
+
},
|
|
624
|
+
"selection": {
|
|
625
|
+
"layers": _layers_json(layers),
|
|
626
|
+
"transforms": list(transforms),
|
|
627
|
+
"allow_mirror": allow_mirror,
|
|
628
|
+
},
|
|
629
|
+
"summary": {
|
|
630
|
+
"candidate_count": len(candidates),
|
|
631
|
+
"match_count": sum(1 for candidate in candidates if candidate.status == "identical"),
|
|
632
|
+
"difference_count": sum(1 for candidate in candidates if candidate.status != "identical"),
|
|
633
|
+
},
|
|
634
|
+
"candidates": [
|
|
635
|
+
{
|
|
636
|
+
"status": candidate.status,
|
|
637
|
+
"transform": candidate.transform,
|
|
638
|
+
"origin_dbu": list(candidate.origin_dbu),
|
|
639
|
+
"bbox_dbu": list(candidate.bbox_dbu),
|
|
640
|
+
"template_polygon_count": candidate.polygon_count,
|
|
641
|
+
"target_polygon_count": candidate.target_polygon_count,
|
|
642
|
+
"layers": list(candidate.layers),
|
|
643
|
+
}
|
|
644
|
+
for candidate in candidates
|
|
645
|
+
],
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _render_matches_markdown(result: dict[str, object]) -> str:
|
|
650
|
+
summary = result["summary"]
|
|
651
|
+
lines = [
|
|
652
|
+
"# GDS Template Matches",
|
|
653
|
+
"",
|
|
654
|
+
f"- mode: `{result['mode']}`",
|
|
655
|
+
f"- template: `{result['template']['path']}` top `{result['template']['top']}`",
|
|
656
|
+
f"- target: `{result['target']['path']}` top `{result['target']['top']}`",
|
|
657
|
+
f"- candidate_count: `{summary['candidate_count']}`",
|
|
658
|
+
f"- match_count: `{summary['match_count']}`",
|
|
659
|
+
f"- difference_count: `{summary['difference_count']}`",
|
|
660
|
+
"",
|
|
661
|
+
"| status | transform | bbox_dbu | template_polygons | target_polygons |",
|
|
662
|
+
"|---|---|---|---:|---:|",
|
|
663
|
+
]
|
|
664
|
+
for candidate in result["candidates"]:
|
|
665
|
+
lines.append(
|
|
666
|
+
"| {status} | {transform} | `{bbox}` | {template_count} | {target_count} |".format(
|
|
667
|
+
status=candidate["status"],
|
|
668
|
+
transform=candidate["transform"],
|
|
669
|
+
bbox=candidate["bbox_dbu"],
|
|
670
|
+
template_count=candidate["template_polygon_count"],
|
|
671
|
+
target_count=candidate["target_polygon_count"],
|
|
672
|
+
)
|
|
673
|
+
)
|
|
674
|
+
if result["candidates"]:
|
|
675
|
+
lines.extend(
|
|
676
|
+
[
|
|
677
|
+
"",
|
|
678
|
+
"## Per-Layer Candidate Details",
|
|
679
|
+
"",
|
|
680
|
+
]
|
|
681
|
+
)
|
|
682
|
+
for index, candidate in enumerate(result["candidates"], start=1):
|
|
683
|
+
lines.extend(
|
|
684
|
+
[
|
|
685
|
+
f"### Candidate {index}",
|
|
686
|
+
"",
|
|
687
|
+
f"- status: `{candidate['status']}`",
|
|
688
|
+
f"- transform: `{candidate['transform']}`",
|
|
689
|
+
f"- bbox_dbu: `{candidate['bbox_dbu']}`",
|
|
690
|
+
"",
|
|
691
|
+
"| layer | template_count | target_count | template_area_dbu2 | target_area_dbu2 | xor_area_dbu2 |",
|
|
692
|
+
"|---|---:|---:|---:|---:|---:|",
|
|
693
|
+
]
|
|
694
|
+
)
|
|
695
|
+
for layer in candidate["layers"]:
|
|
696
|
+
lines.append(
|
|
697
|
+
"| {layer}:{datatype} | {template_count} | {target_count} | {template_area} | {target_area} | {xor_area} |".format(
|
|
698
|
+
layer=layer["layer"],
|
|
699
|
+
datatype=layer["datatype"],
|
|
700
|
+
template_count=layer["template_polygon_count"],
|
|
701
|
+
target_count=layer["target_polygon_count"],
|
|
702
|
+
template_area=layer["template_area_dbu2"],
|
|
703
|
+
target_area=layer["target_area_dbu2"],
|
|
704
|
+
xor_area=layer["xor_area_dbu2"],
|
|
705
|
+
)
|
|
706
|
+
)
|
|
707
|
+
lines.append("")
|
|
708
|
+
lines.append("")
|
|
709
|
+
return "\n".join(lines)
|