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.
Files changed (85) hide show
  1. gdsdiff/__init__.py +148 -0
  2. gdsdiff/__main__.py +7 -0
  3. gdsdiff/_environment.py +12 -0
  4. gdsdiff/_version.py +34 -0
  5. gdsdiff/acceptance_evidence.py +530 -0
  6. gdsdiff/api.py +2955 -0
  7. gdsdiff/backends/__init__.py +26 -0
  8. gdsdiff/backends/base.py +77 -0
  9. gdsdiff/backends/boundary_loop_ctypes.py +191 -0
  10. gdsdiff/backends/cpu.py +20 -0
  11. gdsdiff/backends/cpu_ctypes.py +255 -0
  12. gdsdiff/backends/cuda.py +40 -0
  13. gdsdiff/backends/cuda_ctypes.py +1297 -0
  14. gdsdiff/backends/exact.py +424 -0
  15. gdsdiff/backends/geometry_ctypes.py +252 -0
  16. gdsdiff/backends/identity.py +53 -0
  17. gdsdiff/backends/metal.py +198 -0
  18. gdsdiff/backends/metal_ctypes.py +866 -0
  19. gdsdiff/backends/polygon_extract_ctypes.py +233 -0
  20. gdsdiff/backends/structural_ctypes.py +130 -0
  21. gdsdiff/boundary_loops.py +616 -0
  22. gdsdiff/cache.py +544 -0
  23. gdsdiff/canonicalize.py +176 -0
  24. gdsdiff/cli.py +352 -0
  25. gdsdiff/config.py +257 -0
  26. gdsdiff/cuda_setup.py +174 -0
  27. gdsdiff/design_history.py +65 -0
  28. gdsdiff/diagnostics.py +42 -0
  29. gdsdiff/diff_gds.py +66 -0
  30. gdsdiff/exact_diff_markdown.py +979 -0
  31. gdsdiff/exact_oracle.py +649 -0
  32. gdsdiff/extract.py +376 -0
  33. gdsdiff/gds_metadata.py +43 -0
  34. gdsdiff/geometry.py +112 -0
  35. gdsdiff/grid.py +143 -0
  36. gdsdiff/hierarchy.py +215 -0
  37. gdsdiff/hierarchy_extract.py +263 -0
  38. gdsdiff/inventory.py +153 -0
  39. gdsdiff/multiprocessing_support.py +39 -0
  40. gdsdiff/native.py +135 -0
  41. gdsdiff/native_cache.py +265 -0
  42. gdsdiff/native_src/cpu_prefilter.cpp +349 -0
  43. gdsdiff/native_src/gds_boundary_loops.cpp +505 -0
  44. gdsdiff/native_src/gds_geometry_scan.cpp +601 -0
  45. gdsdiff/native_src/gds_polygon_extract.cpp +594 -0
  46. gdsdiff/native_src/gds_structural_scan.cpp +335 -0
  47. gdsdiff/native_src/gdsdiff/CMakeLists.txt +74 -0
  48. gdsdiff/native_src/gdsdiff/core.cpp +191 -0
  49. gdsdiff/native_src/gdsdiff/core.hpp +96 -0
  50. gdsdiff/native_src/gdsdiff/cuda_assignment.cu +304 -0
  51. gdsdiff/native_src/gdsdiff/cuda_assignment.cuh +33 -0
  52. gdsdiff/native_src/gdsdiff/cuda_candidates.cu +133 -0
  53. gdsdiff/native_src/gdsdiff/cuda_candidates.cuh +17 -0
  54. gdsdiff/native_src/gdsdiff/cuda_ctypes.cu +4528 -0
  55. gdsdiff/native_src/gdsdiff/cuda_memory.cu +194 -0
  56. gdsdiff/native_src/gdsdiff/cuda_memory.cuh +34 -0
  57. gdsdiff/native_src/gdsdiff/metal_ctypes.mm +2791 -0
  58. gdsdiff/native_src/gdsdiff/python_bindings.cpp +21 -0
  59. gdsdiff/native_src/gdsdiff/test_core.cpp +93 -0
  60. gdsdiff/native_src/gdsdiff/test_cuda_assignment.cu +109 -0
  61. gdsdiff/native_src/gdsdiff/test_cuda_candidates.cu +94 -0
  62. gdsdiff/native_src/gdsdiff/test_cuda_memory.cu +97 -0
  63. gdsdiff/policy.py +305 -0
  64. gdsdiff/polygon_components.py +860 -0
  65. gdsdiff/profiles.py +152 -0
  66. gdsdiff/py.typed +1 -0
  67. gdsdiff/rect_coverage.py +384 -0
  68. gdsdiff/report.py +354 -0
  69. gdsdiff/schemas/gdsdiff_report-v1.schema.json +92 -0
  70. gdsdiff/semantics.py +75 -0
  71. gdsdiff/source_edge_diff.py +1120 -0
  72. gdsdiff/spatial.py +71 -0
  73. gdsdiff/structural_guard.py +161 -0
  74. gdsdiff/surplus_coverage.py +255 -0
  75. gdsdiff/synthetic_suite.py +1643 -0
  76. gdsdiff/templates.py +709 -0
  77. gdsdiff/tiling.py +153 -0
  78. gdsdiff/windowed_exact.py +270 -0
  79. gdsdiff/windows.py +184 -0
  80. gdsdiff-0.1.0.dist-info/METADATA +135 -0
  81. gdsdiff-0.1.0.dist-info/RECORD +85 -0
  82. gdsdiff-0.1.0.dist-info/WHEEL +5 -0
  83. gdsdiff-0.1.0.dist-info/entry_points.txt +4 -0
  84. gdsdiff-0.1.0.dist-info/licenses/LICENSE +674 -0
  85. gdsdiff-0.1.0.dist-info/top_level.txt +1 -0
gdsdiff/extract.py ADDED
@@ -0,0 +1,376 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from decimal import Decimal
6
+ from fractions import Fraction
7
+ from pathlib import Path
8
+ from typing import Iterable
9
+
10
+ import numpy as np
11
+
12
+ from .config import LayerSpec, parse_layer_selector
13
+ from ._environment import get_environment
14
+ from .gds_metadata import decimal_from_number
15
+ from .geometry import LayerTable, PolygonBuffer, normalize_polygon_points, polygon_bbox, polygon_flags
16
+ from .grid import DEFAULT_COORDINATE_SAFETY_LIMIT, snap_to_grid
17
+ from .inventory import select_top_cell
18
+ from .multiprocessing_support import fork_context
19
+ from .semantics import PathPolicy
20
+
21
+ _WORKER_RAW_POLYGONS = None
22
+ _WORKER_SELECTED_LAYERS = None
23
+ _WORKER_GRID = None
24
+ _WORKER_UNIT = None
25
+ _WORKER_FAST_SCALE = None
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ExtractionStats:
30
+ raw_polygon_count: int
31
+ kept_polygon_count: int
32
+ vertex_count: int
33
+ max_snap_error_dbu: Decimal
34
+
35
+ def to_json(self) -> dict[str, object]:
36
+ return {
37
+ "raw_polygon_count": self.raw_polygon_count,
38
+ "kept_polygon_count": self.kept_polygon_count,
39
+ "vertex_count": self.vertex_count,
40
+ "max_snap_error_dbu": str(self.max_snap_error_dbu),
41
+ }
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ExtractionResult:
46
+ path: Path
47
+ top_name: str
48
+ buffer: PolygonBuffer
49
+ stats: ExtractionStats
50
+
51
+
52
+ def extract_gds_polygons(
53
+ path: str | Path,
54
+ *,
55
+ top_name: str | None = None,
56
+ layers: str | LayerSpec | Iterable[str | tuple[int, int] | LayerSpec] = "all",
57
+ meters_per_dbu: Decimal | str | float | None = None,
58
+ path_policy: PathPolicy | str = PathPolicy.GDSTK,
59
+ ) -> ExtractionResult:
60
+ import gdstk
61
+
62
+ path = Path(path)
63
+ path_policy = PathPolicy.coerce(path_policy)
64
+ if path_policy == PathPolicy.REPORT_ONLY:
65
+ raise ValueError("report-only path policy cannot produce a passing extraction")
66
+ lib = gdstk.read_gds(str(path))
67
+ top = select_top_cell(lib, top_name=top_name)
68
+ if path_policy == PathPolicy.REJECT and top.get_paths():
69
+ raise ValueError("selected geometry contains paths and path_policy='reject'")
70
+ grid = decimal_from_number(meters_per_dbu) if meters_per_dbu is not None else decimal_from_number(lib.precision)
71
+ unit = decimal_from_number(lib.unit)
72
+ raw_polygons = top.get_polygons(apply_repetitions=True, include_paths=True)
73
+ selected_layers = _selected_layer_set(layers, raw_polygons)
74
+ layer_table = LayerTable(tuple(sorted(selected_layers)))
75
+ layer_index = layer_table.index_by_layer
76
+
77
+ records, max_error = _extract_polygon_records(raw_polygons, selected_layers, grid, unit)
78
+
79
+ all_vertices: list[tuple[int, int]] = []
80
+ offsets: list[int] = [0]
81
+ layer_ids: list[int] = []
82
+ bboxes: list[tuple[int, int, int, int]] = []
83
+ flags: list[int] = []
84
+ source_ids: list[int] = []
85
+
86
+ for source_id, spec, normalized, bbox, polygon_flag in records:
87
+ all_vertices.extend(normalized)
88
+ offsets.append(len(all_vertices))
89
+ layer_ids.append(layer_index[spec])
90
+ bboxes.append(bbox)
91
+ flags.append(polygon_flag)
92
+ source_ids.append(source_id)
93
+
94
+ buffer = PolygonBuffer(
95
+ vertices_xy=np.asarray(all_vertices, dtype=np.int64).reshape((-1, 2)),
96
+ polygon_offsets=np.asarray(offsets, dtype=np.uint64),
97
+ layer_ids=np.asarray(layer_ids, dtype=np.uint32),
98
+ bboxes=np.asarray(bboxes, dtype=np.int64).reshape((-1, 4)),
99
+ flags=np.asarray(flags, dtype=np.uint32),
100
+ source_ids=np.asarray(source_ids, dtype=np.uint64),
101
+ layer_table=layer_table,
102
+ )
103
+ return ExtractionResult(
104
+ path=path,
105
+ top_name=top.name,
106
+ buffer=buffer,
107
+ stats=ExtractionStats(
108
+ raw_polygon_count=len(raw_polygons),
109
+ kept_polygon_count=buffer.polygon_count,
110
+ vertex_count=buffer.vertex_count,
111
+ max_snap_error_dbu=max_error,
112
+ ),
113
+ )
114
+
115
+
116
+ def _selected_layer_set(layers, raw_polygons: list[gdstk.Polygon]) -> set[LayerSpec]:
117
+ if layers == "all":
118
+ return {LayerSpec(polygon.layer, polygon.datatype) for polygon in raw_polygons}
119
+ parsed = parse_layer_selector(layers)
120
+ if parsed == "all":
121
+ return {LayerSpec(polygon.layer, polygon.datatype) for polygon in raw_polygons}
122
+ return set(parsed)
123
+
124
+
125
+ def _extract_polygon_records(
126
+ raw_polygons: list[gdstk.Polygon],
127
+ selected_layers: set[LayerSpec],
128
+ grid: Decimal,
129
+ unit: Decimal,
130
+ ) -> tuple[
131
+ list[tuple[int, LayerSpec, tuple[tuple[int, int], ...], tuple[int, int, int, int], int]],
132
+ Decimal,
133
+ ]:
134
+ fast_scale = _fast_integer_snap_scale(unit, grid)
135
+ if fast_scale is not None:
136
+ return _extract_polygon_records_fast(raw_polygons, selected_layers, fast_scale)
137
+
138
+ worker_count = _resolve_extract_worker_count(len(raw_polygons))
139
+ if worker_count <= 1:
140
+ return _extract_polygon_records_serial(raw_polygons, selected_layers, grid, unit)
141
+
142
+ chunk_count = max(worker_count, worker_count * 8)
143
+ chunk_size = max(1, (len(raw_polygons) + chunk_count - 1) // chunk_count)
144
+ chunks = tuple((start, min(start + chunk_size, len(raw_polygons))) for start in range(0, len(raw_polygons), chunk_size))
145
+ context = fork_context()
146
+ if context is None:
147
+ return _extract_polygon_records_serial(raw_polygons, selected_layers, grid, unit)
148
+
149
+ global _WORKER_RAW_POLYGONS, _WORKER_SELECTED_LAYERS, _WORKER_GRID, _WORKER_UNIT
150
+ _WORKER_RAW_POLYGONS = raw_polygons
151
+ _WORKER_SELECTED_LAYERS = selected_layers
152
+ _WORKER_GRID = grid
153
+ _WORKER_UNIT = unit
154
+ try:
155
+ try:
156
+ with context.Pool(processes=worker_count) as pool:
157
+ chunk_results = pool.map(_extract_polygon_records_worker, chunks, chunksize=1)
158
+ except OSError:
159
+ return _extract_polygon_records_serial(raw_polygons, selected_layers, grid, unit)
160
+ finally:
161
+ _WORKER_RAW_POLYGONS = None
162
+ _WORKER_SELECTED_LAYERS = None
163
+ _WORKER_GRID = None
164
+ _WORKER_UNIT = None
165
+
166
+ records = []
167
+ max_error = Decimal(0)
168
+ for chunk_records, chunk_max_error in chunk_results:
169
+ records.extend(chunk_records)
170
+ max_error = max(max_error, chunk_max_error)
171
+ records.sort(key=lambda record: record[0])
172
+ return records, max_error
173
+
174
+
175
+ def _fast_integer_snap_scale(unit: Decimal, grid: Decimal) -> int | None:
176
+ if get_environment("GDSDIFF_EXTRACT_DECIMAL", "GDS_GPU_COMPARE_EXTRACT_DECIMAL") == "1":
177
+ return None
178
+ try:
179
+ ratio = Fraction(unit) / Fraction(grid)
180
+ except (ArithmeticError, ValueError):
181
+ return None
182
+ if ratio.denominator != 1:
183
+ return None
184
+ scale = ratio.numerator
185
+ if scale <= 0:
186
+ return None
187
+ return scale
188
+
189
+
190
+ def _extract_polygon_records_fast(
191
+ raw_polygons: list[gdstk.Polygon],
192
+ selected_layers: set[LayerSpec],
193
+ scale: int,
194
+ ) -> tuple[
195
+ list[tuple[int, LayerSpec, tuple[tuple[int, int], ...], tuple[int, int, int, int], int]],
196
+ Decimal,
197
+ ]:
198
+ worker_count = _resolve_extract_worker_count(len(raw_polygons))
199
+ if worker_count <= 1:
200
+ return _extract_polygon_records_fast_from_range(raw_polygons, selected_layers, scale, 0, len(raw_polygons))
201
+
202
+ chunk_count = max(worker_count, worker_count * 8)
203
+ chunk_size = max(1, (len(raw_polygons) + chunk_count - 1) // chunk_count)
204
+ chunks = tuple((start, min(start + chunk_size, len(raw_polygons))) for start in range(0, len(raw_polygons), chunk_size))
205
+ context = fork_context()
206
+ if context is None:
207
+ return _extract_polygon_records_fast_from_range(raw_polygons, selected_layers, scale, 0, len(raw_polygons))
208
+
209
+ global _WORKER_RAW_POLYGONS, _WORKER_SELECTED_LAYERS, _WORKER_FAST_SCALE
210
+ _WORKER_RAW_POLYGONS = raw_polygons
211
+ _WORKER_SELECTED_LAYERS = selected_layers
212
+ _WORKER_FAST_SCALE = scale
213
+ try:
214
+ try:
215
+ with context.Pool(processes=worker_count) as pool:
216
+ chunk_results = pool.map(_extract_polygon_records_fast_worker, chunks, chunksize=1)
217
+ except OSError:
218
+ return _extract_polygon_records_fast_from_range(raw_polygons, selected_layers, scale, 0, len(raw_polygons))
219
+ finally:
220
+ _WORKER_RAW_POLYGONS = None
221
+ _WORKER_SELECTED_LAYERS = None
222
+ _WORKER_FAST_SCALE = None
223
+
224
+ records = []
225
+ max_error = Decimal(0)
226
+ for chunk_records, chunk_max_error in chunk_results:
227
+ records.extend(chunk_records)
228
+ max_error = max(max_error, chunk_max_error)
229
+ records.sort(key=lambda record: record[0])
230
+ return records, max_error
231
+
232
+
233
+ def _extract_polygon_records_fast_worker(
234
+ chunk: tuple[int, int],
235
+ ) -> tuple[
236
+ list[tuple[int, LayerSpec, tuple[tuple[int, int], ...], tuple[int, int, int, int], int]],
237
+ Decimal,
238
+ ]:
239
+ if _WORKER_RAW_POLYGONS is None or _WORKER_SELECTED_LAYERS is None or _WORKER_FAST_SCALE is None:
240
+ raise RuntimeError("fast extraction worker globals are not initialized")
241
+ start, stop = chunk
242
+ return _extract_polygon_records_fast_from_range(
243
+ _WORKER_RAW_POLYGONS,
244
+ _WORKER_SELECTED_LAYERS,
245
+ _WORKER_FAST_SCALE,
246
+ start,
247
+ stop,
248
+ )
249
+
250
+
251
+ def _extract_polygon_records_fast_from_range(
252
+ raw_polygons: list[gdstk.Polygon],
253
+ selected_layers: set[LayerSpec],
254
+ scale: int,
255
+ start: int,
256
+ stop: int,
257
+ ) -> tuple[
258
+ list[tuple[int, LayerSpec, tuple[tuple[int, int], ...], tuple[int, int, int, int], int]],
259
+ Decimal,
260
+ ]:
261
+ records = []
262
+ max_error = 0.0
263
+ float_scale = float(scale)
264
+ for source_id in range(start, stop):
265
+ polygon = raw_polygons[source_id]
266
+ spec = LayerSpec(polygon.layer, polygon.datatype)
267
+ if spec not in selected_layers:
268
+ continue
269
+ points = np.asarray(polygon.points, dtype=np.float64)
270
+ if points.size == 0:
271
+ continue
272
+ scaled = points * float_scale
273
+ snapped = _round_half_away_from_zero(scaled)
274
+ if snapped.size and np.max(np.abs(snapped)) > DEFAULT_COORDINATE_SAFETY_LIMIT:
275
+ raise OverflowError("coordinate exceeds configured safety limit")
276
+ max_error = max(max_error, float(np.max(np.abs(scaled - snapped))))
277
+ snapped_points = tuple((int(x), int(y)) for x, y in snapped.astype(np.int64, copy=False))
278
+ normalized = normalize_polygon_points(snapped_points)
279
+ if not normalized:
280
+ continue
281
+ records.append((source_id, spec, normalized, polygon_bbox(normalized), polygon_flags(normalized)))
282
+ return records, Decimal(str(max_error))
283
+
284
+
285
+ def _round_half_away_from_zero(values: np.ndarray) -> np.ndarray:
286
+ return np.copysign(np.floor(np.abs(values) + 0.5), values)
287
+
288
+
289
+ def _resolve_extract_worker_count(raw_polygon_count: int) -> int:
290
+ if raw_polygon_count < _parallel_min_polygons():
291
+ return 1
292
+ return max(1, os.cpu_count() or 1)
293
+
294
+
295
+ def _parallel_min_polygons() -> int:
296
+ try:
297
+ value = int(
298
+ get_environment(
299
+ "GDSDIFF_PARALLEL_MIN_POLYGONS",
300
+ "GDS_GPU_COMPARE_PARALLEL_MIN_POLYGONS",
301
+ "",
302
+ )
303
+ or ""
304
+ )
305
+ except ValueError:
306
+ return 30_000
307
+ return value if value > 0 else 30_000
308
+
309
+
310
+ def _extract_polygon_records_serial(
311
+ raw_polygons: list[gdstk.Polygon],
312
+ selected_layers: set[LayerSpec],
313
+ grid: Decimal,
314
+ unit: Decimal,
315
+ ) -> tuple[
316
+ list[tuple[int, LayerSpec, tuple[tuple[int, int], ...], tuple[int, int, int, int], int]],
317
+ Decimal,
318
+ ]:
319
+ return _extract_polygon_records_from_range(raw_polygons, selected_layers, grid, unit, 0, len(raw_polygons))
320
+
321
+
322
+ def _extract_polygon_records_worker(
323
+ chunk: tuple[int, int],
324
+ ) -> tuple[
325
+ list[tuple[int, LayerSpec, tuple[tuple[int, int], ...], tuple[int, int, int, int], int]],
326
+ Decimal,
327
+ ]:
328
+ if (
329
+ _WORKER_RAW_POLYGONS is None
330
+ or _WORKER_SELECTED_LAYERS is None
331
+ or _WORKER_GRID is None
332
+ or _WORKER_UNIT is None
333
+ ):
334
+ raise RuntimeError("extraction worker globals are not initialized")
335
+ start, stop = chunk
336
+ return _extract_polygon_records_from_range(
337
+ _WORKER_RAW_POLYGONS,
338
+ _WORKER_SELECTED_LAYERS,
339
+ _WORKER_GRID,
340
+ _WORKER_UNIT,
341
+ start,
342
+ stop,
343
+ )
344
+
345
+
346
+ def _extract_polygon_records_from_range(
347
+ raw_polygons: list[gdstk.Polygon],
348
+ selected_layers: set[LayerSpec],
349
+ grid: Decimal,
350
+ unit: Decimal,
351
+ start: int,
352
+ stop: int,
353
+ ) -> tuple[
354
+ list[tuple[int, LayerSpec, tuple[tuple[int, int], ...], tuple[int, int, int, int], int]],
355
+ Decimal,
356
+ ]:
357
+ records = []
358
+ max_error = Decimal(0)
359
+ for source_id in range(start, stop):
360
+ polygon = raw_polygons[source_id]
361
+ spec = LayerSpec(polygon.layer, polygon.datatype)
362
+ if spec not in selected_layers:
363
+ continue
364
+ snapped_points: list[tuple[int, int]] = []
365
+ for point in polygon.points:
366
+ x_physical = decimal_from_number(float(point[0])) * unit
367
+ y_physical = decimal_from_number(float(point[1])) * unit
368
+ x = snap_to_grid(x_physical, grid)
369
+ y = snap_to_grid(y_physical, grid)
370
+ max_error = max(max_error, abs(x_physical / grid - Decimal(x)), abs(y_physical / grid - Decimal(y)))
371
+ snapped_points.append((x, y))
372
+ normalized = normalize_polygon_points(snapped_points)
373
+ if not normalized:
374
+ continue
375
+ records.append((source_id, spec, normalized, polygon_bbox(normalized), polygon_flags(normalized)))
376
+ return records, max_error
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from decimal import Decimal
5
+ from pathlib import Path
6
+
7
+
8
+ def decimal_from_number(value: int | float | str | Decimal) -> Decimal:
9
+ if isinstance(value, Decimal):
10
+ return value
11
+ return Decimal(str(value))
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class GdsMetadata:
16
+ path: Path
17
+ unit_m: Decimal
18
+ precision_m: Decimal
19
+ cell_count: int
20
+ top_names: tuple[str, ...]
21
+
22
+ def to_json(self) -> dict[str, object]:
23
+ return {
24
+ "path": str(self.path),
25
+ "unit_m": str(self.unit_m),
26
+ "precision_m": str(self.precision_m),
27
+ "cell_count": self.cell_count,
28
+ "top_names": list(self.top_names),
29
+ }
30
+
31
+
32
+ def read_gds_metadata(path: str | Path) -> GdsMetadata:
33
+ import gdstk
34
+
35
+ path = Path(path)
36
+ lib = gdstk.read_gds(str(path))
37
+ return GdsMetadata(
38
+ path=path,
39
+ unit_m=decimal_from_number(lib.unit),
40
+ precision_m=decimal_from_number(lib.precision),
41
+ cell_count=len(lib.cells),
42
+ top_names=tuple(cell.name for cell in lib.top_level()),
43
+ )
gdsdiff/geometry.py ADDED
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+ from .config import LayerSpec
8
+
9
+ FLAG_FEWER_THAN_THREE_POINTS = 1 << 0
10
+ FLAG_ZERO_AREA = 1 << 1
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class LayerTable:
15
+ layers: tuple[LayerSpec, ...]
16
+
17
+ def __post_init__(self) -> None:
18
+ if len(set(self.layers)) != len(self.layers):
19
+ raise ValueError("LayerTable cannot contain duplicate layers")
20
+
21
+ @property
22
+ def index_by_layer(self) -> dict[LayerSpec, int]:
23
+ return {layer: index for index, layer in enumerate(self.layers)}
24
+
25
+ def to_json(self) -> list[dict[str, int]]:
26
+ return [layer.to_json() for layer in self.layers]
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class PolygonBuffer:
31
+ vertices_xy: np.ndarray
32
+ polygon_offsets: np.ndarray
33
+ layer_ids: np.ndarray
34
+ bboxes: np.ndarray
35
+ flags: np.ndarray
36
+ source_ids: np.ndarray
37
+ layer_table: LayerTable
38
+ polygon_hashes: np.ndarray | None = None
39
+
40
+ def __post_init__(self) -> None:
41
+ if self.vertices_xy.dtype != np.int64 or self.vertices_xy.ndim != 2 or self.vertices_xy.shape[1] != 2:
42
+ raise TypeError("vertices_xy must be an int64 array with shape (N, 2)")
43
+ if self.polygon_offsets.dtype != np.uint64 or self.polygon_offsets.ndim != 1:
44
+ raise TypeError("polygon_offsets must be a uint64 vector")
45
+ if self.layer_ids.dtype != np.uint32 or self.layer_ids.ndim != 1:
46
+ raise TypeError("layer_ids must be a uint32 vector")
47
+ if self.bboxes.dtype != np.int64 or self.bboxes.shape != (self.polygon_count, 4):
48
+ raise TypeError("bboxes must be an int64 array with shape (N_polygons, 4)")
49
+ if self.flags.dtype != np.uint32 or self.flags.shape != (self.polygon_count,):
50
+ raise TypeError("flags must be a uint32 vector with one entry per polygon")
51
+ if self.source_ids.dtype != np.uint64 or self.source_ids.shape != (self.polygon_count,):
52
+ raise TypeError("source_ids must be a uint64 vector with one entry per polygon")
53
+ if self.polygon_hashes is not None and (
54
+ self.polygon_hashes.dtype != np.uint64 or self.polygon_hashes.shape != (self.polygon_count, 2)
55
+ ):
56
+ raise TypeError("polygon_hashes must be a uint64 array with shape (N_polygons, 2)")
57
+ if len(self.polygon_offsets) != self.polygon_count + 1:
58
+ raise ValueError("polygon_offsets length must be polygon_count + 1")
59
+ if len(self.polygon_offsets) and int(self.polygon_offsets[0]) != 0:
60
+ raise ValueError("polygon_offsets must start at zero")
61
+ if len(self.polygon_offsets) and int(self.polygon_offsets[-1]) != self.vertex_count:
62
+ raise ValueError("final polygon offset must equal vertex count")
63
+
64
+ @property
65
+ def polygon_count(self) -> int:
66
+ return len(self.layer_ids)
67
+
68
+ @property
69
+ def vertex_count(self) -> int:
70
+ return len(self.vertices_xy)
71
+
72
+ def polygon_vertices(self, index: int) -> np.ndarray:
73
+ start = int(self.polygon_offsets[index])
74
+ end = int(self.polygon_offsets[index + 1])
75
+ return self.vertices_xy[start:end]
76
+
77
+
78
+ def normalize_polygon_points(points: list[tuple[int, int]]) -> tuple[tuple[int, int], ...]:
79
+ if len(points) > 1 and points[0] == points[-1]:
80
+ points = points[:-1]
81
+ normalized: list[tuple[int, int]] = []
82
+ for point in points:
83
+ if not normalized or normalized[-1] != point:
84
+ normalized.append(point)
85
+ if len(normalized) > 1 and normalized[0] == normalized[-1]:
86
+ normalized.pop()
87
+ return tuple(normalized)
88
+
89
+
90
+ def polygon_bbox(points: tuple[tuple[int, int], ...]) -> tuple[int, int, int, int]:
91
+ xs = [point[0] for point in points]
92
+ ys = [point[1] for point in points]
93
+ return min(xs), min(ys), max(xs), max(ys)
94
+
95
+
96
+ def polygon_twice_area(points: tuple[tuple[int, int], ...]) -> int:
97
+ if len(points) < 3:
98
+ return 0
99
+ area = 0
100
+ for index, point in enumerate(points):
101
+ next_point = points[(index + 1) % len(points)]
102
+ area += point[0] * next_point[1] - next_point[0] * point[1]
103
+ return abs(area)
104
+
105
+
106
+ def polygon_flags(points: tuple[tuple[int, int], ...]) -> int:
107
+ flags = 0
108
+ if len(set(points)) < 3:
109
+ flags |= FLAG_FEWER_THAN_THREE_POINTS
110
+ if polygon_twice_area(points) == 0:
111
+ flags |= FLAG_ZERO_AREA
112
+ return flags
gdsdiff/grid.py ADDED
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from decimal import Decimal
5
+ from fractions import Fraction
6
+ from math import gcd, lcm
7
+
8
+ from .config import GridSpec
9
+ from .gds_metadata import GdsMetadata, decimal_from_number
10
+
11
+ INT64_LIMIT = 2**63 - 1
12
+ DEFAULT_COORDINATE_SAFETY_LIMIT = 2**62
13
+ MAX_AUTO_GRID_DENOMINATOR = 10**18
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class GridResolution:
18
+ grid: GridSpec
19
+ ratio_residual: Decimal
20
+ method: str
21
+
22
+ def to_json(self) -> dict[str, object]:
23
+ data = self.grid.to_json()
24
+ data["ratio_residual"] = str(self.ratio_residual)
25
+ data["method"] = self.method
26
+ return data
27
+
28
+
29
+ def _fraction_from_decimal(value: Decimal) -> Fraction:
30
+ return Fraction(value)
31
+
32
+
33
+ def _decimal_from_fraction(value: Fraction) -> Decimal:
34
+ return Decimal(value.numerator) / Decimal(value.denominator)
35
+
36
+
37
+ def _rational_gcd(left: Fraction, right: Fraction) -> Fraction:
38
+ numerator = gcd(abs(left.numerator), abs(right.numerator))
39
+ denominator = lcm(left.denominator, right.denominator)
40
+ return Fraction(numerator, denominator)
41
+
42
+
43
+ def _integer_related(left: Decimal, right: Decimal) -> bool:
44
+ larger = max(left, right)
45
+ smaller = min(left, right)
46
+ ratio = larger / smaller
47
+ return ratio == ratio.to_integral_value()
48
+
49
+
50
+ def resolve_comparison_grid(
51
+ old: GdsMetadata,
52
+ new: GdsMetadata,
53
+ *,
54
+ dbu: Decimal | str | float | None = None,
55
+ precision: Decimal | str | float | None = None,
56
+ max_denominator: int = MAX_AUTO_GRID_DENOMINATOR,
57
+ ) -> GridResolution:
58
+ explicit_dbu = decimal_from_number(dbu) if dbu is not None else None
59
+ explicit_precision = decimal_from_number(precision) if precision is not None else None
60
+ if explicit_dbu is not None and explicit_precision is not None and explicit_dbu != explicit_precision:
61
+ raise ValueError("explicit dbu and precision must match")
62
+ explicit = explicit_dbu if explicit_dbu is not None else explicit_precision
63
+ if explicit is not None:
64
+ _validate_positive_decimal(explicit, "comparison grid")
65
+ return GridResolution(
66
+ grid=GridSpec(
67
+ meters_per_dbu=explicit,
68
+ source_old_precision_m=old.precision_m,
69
+ source_new_precision_m=new.precision_m,
70
+ selection_mode="explicit",
71
+ ),
72
+ ratio_residual=Decimal(0),
73
+ method="explicit",
74
+ )
75
+
76
+ old_precision = old.precision_m
77
+ new_precision = new.precision_m
78
+ _validate_positive_decimal(old_precision, "old precision")
79
+ _validate_positive_decimal(new_precision, "new precision")
80
+
81
+ if old_precision == new_precision:
82
+ selected = old_precision
83
+ method = "equal-source-precision"
84
+ elif _integer_related(old_precision, new_precision):
85
+ selected = min(old_precision, new_precision)
86
+ method = "integer-related-source-precision"
87
+ else:
88
+ old_fraction = _fraction_from_decimal(old_precision)
89
+ new_fraction = _fraction_from_decimal(new_precision)
90
+ selected_fraction = _rational_gcd(old_fraction, new_fraction)
91
+ if selected_fraction.denominator > max_denominator:
92
+ raise ValueError("automatic grid denominator exceeds limit; pass explicit dbu")
93
+ selected = _decimal_from_fraction(selected_fraction)
94
+ method = "rational-common-subgrid"
95
+
96
+ return GridResolution(
97
+ grid=GridSpec(
98
+ meters_per_dbu=selected,
99
+ source_old_precision_m=old_precision,
100
+ source_new_precision_m=new_precision,
101
+ selection_mode="auto",
102
+ ),
103
+ ratio_residual=Decimal(0),
104
+ method=method,
105
+ )
106
+
107
+
108
+ def snap_to_grid(value_m: Decimal | str | float | int, meters_per_dbu: Decimal | str | float) -> int:
109
+ value = decimal_from_number(value_m)
110
+ q = decimal_from_number(meters_per_dbu)
111
+ _validate_positive_decimal(q, "meters_per_dbu")
112
+ scaled = value / q
113
+ sign = -1 if scaled < 0 else 1
114
+ magnitude = abs(scaled)
115
+ whole = int(magnitude)
116
+ fraction = magnitude - whole
117
+ if fraction >= Decimal("0.5"):
118
+ whole += 1
119
+ snapped = sign * whole
120
+ validate_int64_coordinate(snapped)
121
+ return snapped
122
+
123
+
124
+ def max_snap_error_dbu(values_m: list[Decimal], meters_per_dbu: Decimal) -> Decimal:
125
+ if not values_m:
126
+ return Decimal(0)
127
+ errors = []
128
+ for value in values_m:
129
+ snapped = Decimal(snap_to_grid(value, meters_per_dbu))
130
+ errors.append(abs(value / meters_per_dbu - snapped))
131
+ return max(errors)
132
+
133
+
134
+ def validate_int64_coordinate(value: int, *, safety_limit: int = DEFAULT_COORDINATE_SAFETY_LIMIT) -> None:
135
+ if abs(value) > INT64_LIMIT:
136
+ raise OverflowError("coordinate exceeds signed int64 range")
137
+ if abs(value) > safety_limit:
138
+ raise OverflowError("coordinate exceeds configured safety limit")
139
+
140
+
141
+ def _validate_positive_decimal(value: Decimal, name: str) -> None:
142
+ if not value.is_finite() or value <= 0:
143
+ raise ValueError(f"{name} must be finite and positive")