prefab 0.4.7__py3-none-any.whl → 1.1.8__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.
prefab/device.py ADDED
@@ -0,0 +1,1486 @@
1
+ """Provides the Device class for representing photonic devices."""
2
+
3
+ from typing import TYPE_CHECKING, Literal, Optional
4
+
5
+ import cv2
6
+ import gdstk
7
+ import matplotlib.pyplot as plt
8
+ import numpy as np
9
+ from matplotlib.axes import Axes
10
+ from matplotlib.patches import Rectangle
11
+ from PIL import Image
12
+ from pydantic import BaseModel, Field, model_validator, validator
13
+ from scipy.ndimage import distance_transform_edt
14
+ from skimage import measure
15
+
16
+ from . import compare, geometry
17
+ from .models import Model
18
+ from .predict import predict_array
19
+
20
+ if TYPE_CHECKING:
21
+ import gdsfactory as gf
22
+ import tidy3d as td
23
+
24
+ Image.MAX_IMAGE_PIXELS = None
25
+
26
+
27
+ class BufferSpec(BaseModel):
28
+ """
29
+ Defines the specifications for a buffer zone around a device.
30
+
31
+ This class is used to specify the mode and thickness of a buffer zone that is added
32
+ around the device geometry. The buffer zone can be used for various purposes such as
33
+ providing extra space for device fabrication processes or for ensuring that the
34
+ device is isolated from surrounding structures.
35
+
36
+ Parameters
37
+ ----------
38
+ mode : dict[str, str]
39
+ A dictionary that defines the buffer mode for each side of the device
40
+ ('top', 'bottom', 'left', 'right'), where:
41
+ - 'constant' is used for isolated structures
42
+ - 'edge' is utilized for preserving the edge, such as for waveguide connections
43
+ - 'none' for no buffer on that side
44
+ thickness : dict[str, int]
45
+ A dictionary that defines the thickness of the buffer zone for each side of the
46
+ device ('top', 'bottom', 'left', 'right'). Each value must be greater than or
47
+ equal to 0.
48
+
49
+ Raises
50
+ ------
51
+ ValueError
52
+ If any of the modes specified in the 'mode' dictionary are not one of the
53
+ allowed values ('constant', 'edge', 'none'). Or if any of the thickness values
54
+ are negative.
55
+
56
+ Example
57
+ -------
58
+ import prefab as pf
59
+
60
+ buffer_spec = pf.BufferSpec(
61
+ mode={
62
+ "top": "constant",
63
+ "bottom": "none",
64
+ "left": "constant",
65
+ "right": "edge",
66
+ },
67
+ thickness={
68
+ "top": 150,
69
+ "bottom": 0,
70
+ "left": 200,
71
+ "right": 250,
72
+ },
73
+ )
74
+ """
75
+
76
+ mode: dict[str, Literal["constant", "edge", "none"]] = Field(
77
+ default_factory=lambda: {
78
+ "top": "constant",
79
+ "bottom": "constant",
80
+ "left": "constant",
81
+ "right": "constant",
82
+ }
83
+ )
84
+ thickness: dict[str, int] = Field(
85
+ default_factory=lambda: {
86
+ "top": 128,
87
+ "bottom": 128,
88
+ "left": 128,
89
+ "right": 128,
90
+ }
91
+ )
92
+
93
+ @validator("mode", pre=True)
94
+ def check_mode(cls, v):
95
+ allowed_modes = ["constant", "edge", "none"]
96
+ if not all(mode in allowed_modes for mode in v.values()):
97
+ raise ValueError(f"Buffer mode must be one of {allowed_modes}, got '{v}'.")
98
+ return v
99
+
100
+ @validator("thickness")
101
+ def check_thickness(cls, v):
102
+ if not all(t >= 0 for t in v.values()):
103
+ raise ValueError("All thickness values must be greater than or equal to 0.")
104
+ return v
105
+
106
+ @model_validator(mode="after")
107
+ def check_none_thickness(cls, values):
108
+ mode = values.mode
109
+ thickness = values.thickness
110
+ for side in mode:
111
+ if mode[side] == "none" and thickness[side] != 0:
112
+ raise ValueError(
113
+ f"Thickness must be 0 when mode is 'none' for {side} side"
114
+ )
115
+ if mode[side] != "none" and thickness[side] == 0:
116
+ raise ValueError(
117
+ f"Mode must be 'none' when thickness is 0 for {side} side"
118
+ )
119
+ return values
120
+
121
+
122
+ class Device(BaseModel):
123
+ device_array: np.ndarray = Field(...)
124
+ buffer_spec: BufferSpec = Field(default_factory=BufferSpec)
125
+
126
+ class Config:
127
+ arbitrary_types_allowed = True
128
+
129
+ @property
130
+ def shape(self) -> tuple[int, ...]:
131
+ return tuple(self.device_array.shape)
132
+
133
+ def __init__(
134
+ self, device_array: np.ndarray, buffer_spec: Optional[BufferSpec] = None
135
+ ):
136
+ """
137
+ Represents the planar geometry of a photonic device design that will have its
138
+ nanofabrication outcome predicted and/or corrected.
139
+
140
+ This class is designed to encapsulate the geometric representation of a photonic
141
+ device, facilitating operations such as padding, normalization, binarization,
142
+ erosion/dilation, trimming, and blurring. These operations are useful for
143
+ preparingthe device design for prediction or correction. Additionally, the class
144
+ providesmethods for exporting the device representation to various formats,
145
+ includingndarray, image files, and GDSII files, supporting a range of analysis
146
+ and fabrication workflows.
147
+
148
+ Parameters
149
+ ----------
150
+ device_array : np.ndarray
151
+ A 2D array representing the planar geometry of the device. This array
152
+ undergoes various transformations to predict or correct the nanofabrication
153
+ process.
154
+ buffer_spec : Optional[BufferSpec]
155
+ Defines the parameters for adding a buffer zone around the device geometry.
156
+ This buffer zone is needed for providing surrounding context for prediction
157
+ or correction and for ensuring seamless integration with the surrounding
158
+ circuitry. By default, a generous padding is applied to accommodate isolated
159
+ structures.
160
+
161
+ Attributes
162
+ ----------
163
+ shape : tuple[int, int]
164
+ The shape of the device array.
165
+
166
+ Raises
167
+ ------
168
+ ValueError
169
+ If the provided `device_array` is not a numpy ndarray or is not a 2D array,
170
+ indicating an invalid device geometry.
171
+ """
172
+ super().__init__(
173
+ device_array=device_array, buffer_spec=buffer_spec or BufferSpec()
174
+ )
175
+ self._initial_processing()
176
+
177
+ def __call__(self, *args, **kwargs):
178
+ return self.plot(*args, **kwargs)
179
+
180
+ def _initial_processing(self):
181
+ buffer_thickness = self.buffer_spec.thickness
182
+ buffer_mode = self.buffer_spec.mode
183
+
184
+ if buffer_mode["top"] != "none":
185
+ self.device_array = np.pad(
186
+ self.device_array,
187
+ pad_width=((buffer_thickness["top"], 0), (0, 0)),
188
+ mode=buffer_mode["top"],
189
+ )
190
+
191
+ if buffer_mode["bottom"] != "none":
192
+ self.device_array = np.pad(
193
+ self.device_array,
194
+ pad_width=((0, buffer_thickness["bottom"]), (0, 0)),
195
+ mode=buffer_mode["bottom"],
196
+ )
197
+
198
+ if buffer_mode["left"] != "none":
199
+ self.device_array = np.pad(
200
+ self.device_array,
201
+ pad_width=((0, 0), (buffer_thickness["left"], 0)),
202
+ mode=buffer_mode["left"],
203
+ )
204
+
205
+ if buffer_mode["right"] != "none":
206
+ self.device_array = np.pad(
207
+ self.device_array,
208
+ pad_width=((0, 0), (0, buffer_thickness["right"])),
209
+ mode=buffer_mode["right"],
210
+ )
211
+
212
+ self.device_array = np.expand_dims(self.device_array, axis=-1)
213
+
214
+ @model_validator(mode="before")
215
+ def check_device_array(cls, values):
216
+ device_array = values.get("device_array")
217
+ if not isinstance(device_array, np.ndarray):
218
+ raise ValueError("device_array must be a numpy ndarray.")
219
+ if device_array.ndim != 2:
220
+ raise ValueError("device_array must be a 2D array.")
221
+ return values
222
+
223
+ @property
224
+ def is_binary(self) -> bool:
225
+ """
226
+ Check if the device geometry is binary.
227
+
228
+ Returns
229
+ -------
230
+ bool
231
+ True if the device geometry is binary, False otherwise.
232
+ """
233
+ unique_values = np.unique(self.device_array)
234
+ return (
235
+ np.array_equal(unique_values, [0, 1])
236
+ or np.array_equal(unique_values, [1, 0])
237
+ or np.array_equal(unique_values, [0])
238
+ or np.array_equal(unique_values, [1])
239
+ )
240
+
241
+ def predict(
242
+ self,
243
+ model: Model,
244
+ binarize: bool = False,
245
+ gpu: bool = False,
246
+ ) -> "Device":
247
+ """
248
+ Predict the nanofabrication outcome of the device using a specified model.
249
+
250
+ This method sends the device geometry to a serverless prediction service, which
251
+ uses a specified machine learning model to predict the outcome of the
252
+ nanofabrication process.
253
+
254
+ Parameters
255
+ ----------
256
+ model : Model
257
+ The model to use for prediction, representing a specific fabrication process
258
+ and dataset. This model encapsulates details about the fabrication foundry,
259
+ process, material, technology, thickness, and sidewall presence, as defined
260
+ in `models.py`. Each model is associated with a version and dataset that
261
+ detail its creation and the data it was trained on, ensuring the prediction
262
+ is tailored to specific fabrication parameters.
263
+ binarize : bool
264
+ If True, the predicted device geometry will be binarized using a threshold
265
+ method. This is useful for converting probabilistic predictions into binary
266
+ geometries. Defaults to False.
267
+ gpu : bool
268
+ If True, the prediction will be performed on a GPU. Defaults to False.
269
+ Note: The GPU option has more overhead and will take longer for small
270
+ devices, but will be faster for larger devices.
271
+
272
+ Returns
273
+ -------
274
+ Device
275
+ A new instance of the Device class with the predicted geometry.
276
+
277
+ Raises
278
+ ------
279
+ RuntimeError
280
+ If the prediction service returns an error or if the response from the
281
+ service cannot be processed correctly.
282
+ """
283
+ prediction_array = predict_array(
284
+ device_array=self.device_array,
285
+ model=model,
286
+ model_type="p",
287
+ binarize=binarize,
288
+ gpu=gpu,
289
+ )
290
+ return self.model_copy(update={"device_array": prediction_array})
291
+
292
+ def correct(
293
+ self,
294
+ model: Model,
295
+ binarize: bool = True,
296
+ gpu: bool = False,
297
+ ) -> "Device":
298
+ """
299
+ Correct the nanofabrication outcome of the device using a specified model.
300
+
301
+ This method sends the device geometry to a serverless correction service, which
302
+ uses a specified machine learning model to correct the outcome of the
303
+ nanofabrication process. The correction aims to adjust the device geometry to
304
+ compensate for known fabrication errors and improve the accuracy of the final
305
+ device structure.
306
+
307
+ Parameters
308
+ ----------
309
+ model : Model
310
+ The model to use for correction, representing a specific fabrication process
311
+ and dataset. This model encapsulates details about the fabrication foundry,
312
+ process, material, technology, thickness, and sidewall presence, as defined
313
+ in `models.py`. Each model is associated with a version and dataset that
314
+ detail its creation and the data it was trained on, ensuring the correction
315
+ is tailored to specific fabrication parameters.
316
+ binarize : bool
317
+ If True, the corrected device geometry will be binarized using a threshold
318
+ method. This is useful for converting probabilistic corrections into binary
319
+ geometries. Defaults to True.
320
+ gpu : bool
321
+ If True, the prediction will be performed on a GPU. Defaults to False.
322
+ Note: The GPU option has more overhead and will take longer for small
323
+ devices, but will be faster for larger devices.
324
+
325
+ Returns
326
+ -------
327
+ Device
328
+ A new instance of the Device class with the corrected geometry.
329
+
330
+ Raises
331
+ ------
332
+ RuntimeError
333
+ If the correction service returns an error or if the response from the
334
+ service cannot be processed correctly.
335
+ """
336
+ correction_array = predict_array(
337
+ device_array=self.device_array,
338
+ model=model,
339
+ model_type="c",
340
+ binarize=binarize,
341
+ gpu=gpu,
342
+ )
343
+ return self.model_copy(update={"device_array": correction_array})
344
+
345
+ def semulate(
346
+ self,
347
+ model: Model,
348
+ gpu: bool = False,
349
+ ) -> "Device":
350
+ """
351
+ Simulate the appearance of the device as if viewed under a scanning electron
352
+ microscope (SEM).
353
+
354
+ This method applies a specified machine learning model to transform the device
355
+ geometry into a style that resembles an SEM image. This can be useful for
356
+ visualizing how the device might appear under an SEM, which is often used for
357
+ inspecting the surface and composition of materials at high magnification.
358
+
359
+ Parameters
360
+ ----------
361
+ model : Model
362
+ The model to use for SEMulation, representing a specific fabrication process
363
+ and dataset. This model encapsulates details about the fabrication foundry,
364
+ process, material, technology, thickness, and sidewall presence, as defined
365
+ in `models.py`. Each model is associated with a version and dataset that
366
+ detail its creation and the data it was trained on, ensuring the SEMulation
367
+ is tailored to specific fabrication parameters.
368
+ gpu : bool
369
+ If True, the prediction will be performed on a GPU. Defaults to False.
370
+ Note: The GPU option has more overhead and will take longer for small
371
+ devices, but will be faster for larger devices.
372
+
373
+ Notes
374
+ -----
375
+ The salt-and-pepper noise is added manually until the model is trained to
376
+ generate this noise (not a big priority).
377
+
378
+ Returns
379
+ -------
380
+ Device
381
+ A new instance of the Device class with its geometry transformed to simulate
382
+ an SEM image style.
383
+
384
+ Raises
385
+ ------
386
+ RuntimeError
387
+ If the prediction service returns an error or if the response from the
388
+ service cannot be processed correctly.
389
+ """
390
+ semulated_array = predict_array(
391
+ device_array=self.device_array,
392
+ model=model,
393
+ model_type="s",
394
+ binarize=False,
395
+ gpu=gpu,
396
+ )
397
+ semulated_array += np.random.normal(0, 0.03, semulated_array.shape)
398
+ return self.model_copy(update={"device_array": semulated_array})
399
+
400
+ def to_ndarray(self) -> np.ndarray:
401
+ """
402
+ Converts the device geometry to an ndarray.
403
+
404
+ This method applies the buffer specifications to crop the device array if
405
+ necessary, based on the buffer mode ('edge' or 'constant'). It then returns the
406
+ resulting ndarray representing the device geometry.
407
+
408
+ Returns
409
+ -------
410
+ np.ndarray
411
+ The ndarray representation of the device geometry, with any applied buffer
412
+ cropping.
413
+ """
414
+ device_array = np.copy(self.device_array)
415
+ buffer_thickness = self.buffer_spec.thickness
416
+ buffer_mode = self.buffer_spec.mode
417
+
418
+ crop_top = buffer_thickness["top"] if buffer_mode["top"] == "constant" else 0
419
+ crop_bottom = (
420
+ buffer_thickness["bottom"] if buffer_mode["bottom"] == "constant" else 0
421
+ )
422
+ crop_left = buffer_thickness["left"] if buffer_mode["left"] == "constant" else 0
423
+ crop_right = (
424
+ buffer_thickness["right"] if buffer_mode["right"] == "constant" else 0
425
+ )
426
+
427
+ ndarray = device_array[
428
+ crop_top : device_array.shape[0] - crop_bottom,
429
+ crop_left : device_array.shape[1] - crop_right,
430
+ ]
431
+ return np.squeeze(ndarray)
432
+
433
+ def to_img(self, img_path: str = "prefab_device.png"):
434
+ """
435
+ Exports the device geometry as an image file.
436
+
437
+ This method converts the device geometry to an ndarray using `to_ndarray`,
438
+ scales the values to the range [0, 255] for image representation, and saves the
439
+ result as an image file.
440
+
441
+ Parameters
442
+ ----------
443
+ img_path : str
444
+ The path where the image file will be saved. If not specified, the image is
445
+ saved as "prefab_device.png" in the current directory.
446
+ """
447
+ cv2.imwrite(img_path, 255 * self.flatten().to_ndarray())
448
+ print(f"Saved Device image to '{img_path}'")
449
+
450
+ def to_gds(
451
+ self,
452
+ gds_path: str = "prefab_device.gds",
453
+ cell_name: str = "prefab_device",
454
+ gds_layer: tuple[int, int] = (1, 0),
455
+ contour_approx_mode: int = 2,
456
+ origin: tuple[float, float] = (0.0, 0.0),
457
+ ):
458
+ """
459
+ Exports the device geometry as a GDSII file.
460
+
461
+ This method converts the device geometry into a format suitable for GDSII files.
462
+ The conversion involves contour approximation to simplify the geometry while
463
+ preserving essential features.
464
+
465
+ Parameters
466
+ ----------
467
+ gds_path : str
468
+ The path where the GDSII file will be saved. If not specified, the file is
469
+ saved as "prefab_device.gds" in the current directory.
470
+ cell_name : str
471
+ The name of the cell within the GDSII file. If not specified, defaults to
472
+ "prefab_device".
473
+ gds_layer : tuple[int, int]
474
+ The layer and datatype to use within the GDSII file. Defaults to (1, 0).
475
+ contour_approx_mode : int
476
+ The mode of contour approximation used during the conversion. Defaults to 2,
477
+ which corresponds to `cv2.CHAIN_APPROX_SIMPLE`, a method that compresses
478
+ horizontal, vertical, and diagonal segments and leaves only their endpoints.
479
+ origin : tuple[float, float]
480
+ The x and y coordinates of the origin in µm for the GDSII export. Defaults
481
+ to (0.0, 0.0).
482
+ """
483
+ gdstk_cell = self.flatten()._device_to_gdstk(
484
+ cell_name=cell_name,
485
+ gds_layer=gds_layer,
486
+ contour_approx_mode=contour_approx_mode,
487
+ origin=origin,
488
+ )
489
+ print(f"Saving GDS to '{gds_path}'...")
490
+ gdstk_library = gdstk.Library()
491
+ gdstk_library.add(gdstk_cell)
492
+ gdstk_library.write_gds(outfile=gds_path, max_points=8190)
493
+
494
+ def to_gdstk(
495
+ self,
496
+ cell_name: str = "prefab_device",
497
+ gds_layer: tuple[int, int] = (1, 0),
498
+ contour_approx_mode: int = 2,
499
+ origin: tuple[float, float] = (0.0, 0.0),
500
+ ):
501
+ """
502
+ Converts the device geometry to a GDSTK cell object.
503
+
504
+ This method prepares the device geometry for GDSII file export by converting it
505
+ into a GDSTK cell object. GDSTK is a Python module for creating and manipulating
506
+ GDSII layout files. The conversion involves contour approximation to simplify
507
+ the geometry while preserving essential features.
508
+
509
+ Parameters
510
+ ----------
511
+ cell_name : str
512
+ The name of the cell to be created. Defaults to "prefab_device".
513
+ gds_layer : tuple[int, int]
514
+ The layer and datatype to use within the GDSTK cell. Defaults to (1, 0).
515
+ contour_approx_mode : int
516
+ The mode of contour approximation used during the conversion. Defaults to 2,
517
+ which corresponds to `cv2.CHAIN_APPROX_SIMPLE`, a method that compresses
518
+ horizontal, vertical, and diagonal segments and leaves only their endpoints.
519
+ origin : tuple[float, float]
520
+ The x and y coordinates of the origin in µm for the GDSTK cell. Defaults
521
+ to (0.0, 0.0).
522
+
523
+ Returns
524
+ -------
525
+ gdstk.Cell
526
+ The GDSTK cell object representing the device geometry.
527
+ """
528
+ print(f"Creating cell '{cell_name}'...")
529
+ gdstk_cell = self.flatten()._device_to_gdstk(
530
+ cell_name=cell_name,
531
+ gds_layer=gds_layer,
532
+ contour_approx_mode=contour_approx_mode,
533
+ origin=origin,
534
+ )
535
+ return gdstk_cell
536
+
537
+ def _device_to_gdstk(
538
+ self,
539
+ cell_name: str,
540
+ gds_layer: tuple[int, int],
541
+ contour_approx_mode: int,
542
+ origin: tuple[float, float],
543
+ ) -> gdstk.Cell:
544
+ approx_mode_mapping = {
545
+ 1: cv2.CHAIN_APPROX_NONE,
546
+ 2: cv2.CHAIN_APPROX_SIMPLE,
547
+ 3: cv2.CHAIN_APPROX_TC89_L1,
548
+ 4: cv2.CHAIN_APPROX_TC89_KCOS,
549
+ }
550
+
551
+ contours, hierarchy = cv2.findContours(
552
+ np.flipud(self.to_ndarray()).astype(np.uint8),
553
+ cv2.RETR_TREE,
554
+ approx_mode_mapping[contour_approx_mode],
555
+ )
556
+
557
+ hierarchy_polygons = {}
558
+ for idx, contour in enumerate(contours):
559
+ level = 0
560
+ current_idx = idx
561
+ while hierarchy[0][current_idx][3] != -1:
562
+ level += 1
563
+ current_idx = hierarchy[0][current_idx][3]
564
+
565
+ if len(contour) > 2:
566
+ contour = contour / 1000
567
+ points = [tuple(point) for point in contour.squeeze().tolist()]
568
+ if level not in hierarchy_polygons:
569
+ hierarchy_polygons[level] = []
570
+ hierarchy_polygons[level].append(points)
571
+
572
+ cell = gdstk.Cell(cell_name)
573
+ processed_polygons = []
574
+ for level in sorted(hierarchy_polygons.keys()):
575
+ operation = "or" if level % 2 == 0 else "xor"
576
+ polygons_to_process = hierarchy_polygons[level]
577
+
578
+ if polygons_to_process:
579
+ buffer_thickness = self.buffer_spec.thickness
580
+
581
+ center_x_nm = (
582
+ self.device_array.shape[1]
583
+ - buffer_thickness["left"]
584
+ - buffer_thickness["right"]
585
+ ) / 2
586
+ center_y_nm = (
587
+ self.device_array.shape[0]
588
+ - buffer_thickness["top"]
589
+ - buffer_thickness["bottom"]
590
+ ) / 2
591
+
592
+ center_x_um = center_x_nm / 1000
593
+ center_y_um = center_y_nm / 1000
594
+
595
+ adjusted_polygons = [
596
+ gdstk.Polygon(
597
+ [
598
+ (x - center_x_um + origin[0], y - center_y_um + origin[1])
599
+ for x, y in polygon
600
+ ]
601
+ )
602
+ for polygon in polygons_to_process
603
+ ]
604
+ processed_polygons = gdstk.boolean(
605
+ adjusted_polygons,
606
+ processed_polygons,
607
+ operation,
608
+ layer=gds_layer[0],
609
+ datatype=gds_layer[1],
610
+ )
611
+ for polygon in processed_polygons:
612
+ cell.add(polygon)
613
+
614
+ return cell
615
+
616
+ def to_gdsfactory(self) -> "gf.Component":
617
+ """
618
+ Convert the device geometry to a gdsfactory Component.
619
+
620
+ Returns
621
+ -------
622
+ gf.Component
623
+ A gdsfactory Component object representing the device geometry.
624
+
625
+ Raises
626
+ ------
627
+ ImportError
628
+ If the gdsfactory package is not installed.
629
+ """
630
+ try:
631
+ import gdsfactory as gf
632
+ except ImportError:
633
+ raise ImportError(
634
+ "The gdsfactory package is required to use this function; "
635
+ "try `pip install gdsfactory`."
636
+ ) from None
637
+
638
+ device_array = np.rot90(self.to_ndarray(), k=-1)
639
+ return gf.read.from_np(device_array, nm_per_pixel=1)
640
+
641
+ def to_tidy3d(
642
+ self,
643
+ eps0: float,
644
+ thickness: float,
645
+ ) -> "td.Structure":
646
+ """
647
+ Convert the device geometry to a Tidy3D Structure.
648
+
649
+ Parameters
650
+ ----------
651
+ eps0 : float
652
+ The permittivity value to assign to the device array.
653
+ thickness : float
654
+ The thickness of the device in the z-direction.
655
+
656
+ Returns
657
+ -------
658
+ td.Structure
659
+ A Tidy3D Structure object representing the device geometry.
660
+
661
+ Raises
662
+ ------
663
+ ImportError
664
+ If the tidy3d package is not installed.
665
+ """
666
+ try:
667
+ from tidy3d import Box, CustomMedium, SpatialDataArray, Structure, inf
668
+ except ImportError:
669
+ raise ImportError(
670
+ "The tidy3d package is required to use this function; "
671
+ "try `pip install tidy3d`."
672
+ ) from None
673
+
674
+ X = np.linspace(-self.shape[1] / 2000, self.shape[1] / 2000, self.shape[1])
675
+ Y = np.linspace(-self.shape[0] / 2000, self.shape[0] / 2000, self.shape[0])
676
+ Z = np.array([0])
677
+
678
+ device_array = np.rot90(np.fliplr(self.device_array), k=1)
679
+ eps_array = np.where(device_array >= 1.0, eps0, device_array)
680
+ eps_array = np.where(eps_array < 1.0, 1.0, eps_array)
681
+ eps_dataset = SpatialDataArray(eps_array, coords=dict(x=X, y=Y, z=Z))
682
+ medium = CustomMedium.from_eps_raw(eps_dataset)
683
+ return Structure(
684
+ geometry=Box(center=(0, 0, 0), size=(inf, inf, thickness), attrs={}),
685
+ medium=medium,
686
+ name="device",
687
+ attrs={},
688
+ )
689
+
690
+ def to_3d(self, thickness_nm: int) -> np.ndarray:
691
+ """
692
+ Convert the 2D device geometry into a 3D representation.
693
+
694
+ This method creates a 3D array by interpolating between the bottom and top
695
+ layers of the device geometry. The interpolation is linear.
696
+
697
+ Parameters
698
+ ----------
699
+ thickness_nm : int
700
+ The thickness of the 3D representation in nanometers.
701
+
702
+ Returns
703
+ -------
704
+ np.ndarray
705
+ A 3D narray representing the device geometry with the specified thickness.
706
+ """
707
+ bottom_layer = self.device_array[:, :, 0]
708
+ top_layer = self.device_array[:, :, -1]
709
+ dt_bottom = np.array(distance_transform_edt(bottom_layer)) - np.array(
710
+ distance_transform_edt(1 - bottom_layer)
711
+ )
712
+ dt_top = np.array(distance_transform_edt(top_layer)) - np.array(
713
+ distance_transform_edt(1 - top_layer)
714
+ )
715
+ weights = np.linspace(0, 1, thickness_nm)
716
+ layered_array = np.zeros(
717
+ (bottom_layer.shape[0], bottom_layer.shape[1], thickness_nm)
718
+ )
719
+ for i, w in enumerate(weights):
720
+ dt_interp = (1 - w) * dt_bottom + w * dt_top
721
+ layered_array[:, :, i] = dt_interp >= 0
722
+ return layered_array
723
+
724
+ def to_stl(self, thickness_nm: int, filename: str = "prefab_device.stl"):
725
+ """
726
+ Export the device geometry as an STL file.
727
+
728
+ Parameters
729
+ ----------
730
+ thickness_nm : int
731
+ The thickness of the 3D representation in nanometers.
732
+ filename : str
733
+ The name of the STL file to save. Defaults to "prefab_device.stl".
734
+
735
+ Raises
736
+ ------
737
+ ValueError
738
+ If the thickness is not a positive integer.
739
+ ImportError
740
+ If the numpy-stl package is not installed.
741
+ """
742
+ try:
743
+ from stl import mesh # type: ignore
744
+ except ImportError:
745
+ raise ImportError(
746
+ "The stl package is required to use this function; "
747
+ "try `pip install numpy-stl`."
748
+ ) from None
749
+
750
+ if thickness_nm <= 0:
751
+ raise ValueError("Thickness must be a positive integer.")
752
+
753
+ layered_array = self.to_3d(thickness_nm)
754
+ layered_array = np.pad(
755
+ layered_array, ((0, 0), (0, 0), (10, 10)), mode="constant"
756
+ )
757
+ verts, faces, _, _ = measure.marching_cubes(layered_array, level=0.5)
758
+ cube = mesh.Mesh(np.zeros(faces.shape[0], dtype=mesh.Mesh.dtype))
759
+ for i, f in enumerate(faces):
760
+ for j in range(3):
761
+ cube.vectors[i][j] = verts[f[j], :]
762
+ cube.save(filename)
763
+ print(f"Saved Device to '{filename}'")
764
+
765
+ def _plot_base(
766
+ self,
767
+ plot_array: np.ndarray,
768
+ show_buffer: bool,
769
+ bounds: Optional[tuple[tuple[int, int], tuple[int, int]]],
770
+ ax: Optional[Axes],
771
+ **kwargs,
772
+ ) -> tuple[plt.cm.ScalarMappable, Axes]:
773
+ if ax is None:
774
+ _, ax = plt.subplots()
775
+ ax.set_ylabel("y (nm)")
776
+ ax.set_xlabel("x (nm)")
777
+
778
+ min_x, min_y = (0, 0) if bounds is None else bounds[0]
779
+ max_x, max_y = plot_array.shape[::-1] if bounds is None else bounds[1]
780
+ min_x = max(min_x, 0)
781
+ min_y = max(min_y, 0)
782
+ max_x = "end" if max_x == "end" else min(max_x, plot_array.shape[1])
783
+ max_y = "end" if max_y == "end" else min(max_y, plot_array.shape[0])
784
+ max_x = plot_array.shape[1] if max_x == "end" else max_x
785
+ max_y = plot_array.shape[0] if max_y == "end" else max_y
786
+ plot_array = plot_array[
787
+ plot_array.shape[0] - max_y : plot_array.shape[0] - min_y,
788
+ min_x:max_x,
789
+ ]
790
+
791
+ if not np.ma.is_masked(plot_array):
792
+ max_size = (1000, 1000)
793
+ scale_x = min(1, max_size[0] / plot_array.shape[1])
794
+ scale_y = min(1, max_size[1] / plot_array.shape[0])
795
+ fx = min(scale_x, scale_y)
796
+ fy = fx
797
+
798
+ plot_array = cv2.resize(
799
+ plot_array,
800
+ dsize=(0, 0),
801
+ fx=fx,
802
+ fy=fy,
803
+ interpolation=cv2.INTER_NEAREST,
804
+ )
805
+
806
+ mappable = ax.imshow(
807
+ plot_array,
808
+ extent=(
809
+ float(min_x),
810
+ float(max_x),
811
+ float(min_y),
812
+ float(max_y),
813
+ ),
814
+ **kwargs,
815
+ )
816
+
817
+ if show_buffer:
818
+ self._add_buffer_visualization(ax)
819
+
820
+ # # Adjust colorbar font size if a colorbar is added
821
+ # if "cmap" in kwargs:
822
+ # cbar = plt.colorbar(mappable, ax=ax)
823
+ # cbar.ax.tick_params(labelsize=14)
824
+ # if "label" in kwargs:
825
+ # cbar.set_label(kwargs["label"], fontsize=16)
826
+
827
+ return mappable, ax
828
+
829
+ def plot(
830
+ self,
831
+ show_buffer: bool = True,
832
+ bounds: Optional[tuple[tuple[int, int], tuple[int, int]]] = None,
833
+ level: Optional[int] = None,
834
+ ax: Optional[Axes] = None,
835
+ **kwargs,
836
+ ) -> Axes:
837
+ """
838
+ Visualizes the device geometry.
839
+
840
+ This method allows for the visualization of the device geometry. The
841
+ visualization can be customized with various matplotlib parameters and can be
842
+ drawn on an existing matplotlib Axes object or create a new one if none is
843
+ provided.
844
+
845
+ Parameters
846
+ ----------
847
+ show_buffer : bool
848
+ If True, visualizes the buffer zones around the device. Defaults to True.
849
+ bounds : Optional[tuple[tuple[int, int], tuple[int, int]]], optional
850
+ Specifies the bounds for zooming into the device geometry, formatted as
851
+ ((min_x, min_y), (max_x, max_y)). If 'max_x' or 'max_y' is set to "end", it
852
+ will be replaced with the corresponding dimension size of the device array.
853
+ If None, the entire device geometry is visualized.
854
+ level : int
855
+ The vertical layer to plot. If None, the device geometry is flattened.
856
+ Defaults to None.
857
+ ax : Optional[Axes]
858
+ An existing matplotlib Axes object to draw the device geometry on. If
859
+ None, a new figure and axes will be created. Defaults to None.
860
+ **kwargs
861
+ Additional matplotlib parameters for plot customization.
862
+
863
+ Returns
864
+ -------
865
+ Axes
866
+ The matplotlib Axes object containing the plot. This object can be used for
867
+ further plot customization or saving the plot after the method returns.
868
+ """
869
+ if level is None:
870
+ plot_array = geometry.flatten(self.device_array)[:, :, 0]
871
+ else:
872
+ plot_array = self.device_array[:, :, level]
873
+ _, ax = self._plot_base(
874
+ plot_array=plot_array,
875
+ show_buffer=show_buffer,
876
+ bounds=bounds,
877
+ ax=ax,
878
+ **kwargs,
879
+ )
880
+ return ax
881
+
882
+ def plot_contour(
883
+ self,
884
+ linewidth: Optional[int] = None,
885
+ # label: Optional[str] = "Device contour",
886
+ show_buffer: bool = True,
887
+ bounds: Optional[tuple[tuple[int, int], tuple[int, int]]] = None,
888
+ level: Optional[int] = None,
889
+ ax: Optional[Axes] = None,
890
+ **kwargs,
891
+ ):
892
+ """
893
+ Visualizes the contour of the device geometry.
894
+
895
+ This method plots the contour of the device geometry, emphasizing the edges and
896
+ boundaries of the device. The contour plot can be customized with various
897
+ matplotlib parameters, including line width and color. The plot can be drawn on
898
+ an existing matplotlib Axes object or create a new one if none is provided.
899
+
900
+ Parameters
901
+ ----------
902
+ linewidth : Optional[int]
903
+ The width of the contour lines. If None, the linewidth is automatically
904
+ determined based on the size of the device array. Defaults to None.
905
+ show_buffer : bool
906
+ If True, the buffer zones around the device will be visualized. By default,
907
+ it is set to True.
908
+ bounds : Optional[tuple[tuple[int, int], tuple[int, int]]]
909
+ Specifies the bounds for zooming into the device geometry, formatted as
910
+ ((min_x, min_y), (max_x, max_y)). If 'max_x' or 'max_y' is set to "end", it
911
+ will be replaced with the corresponding dimension size of the device array.
912
+ If None, the entire device geometry is visualized.
913
+ level : int
914
+ The vertical layer to plot. If None, the device geometry is flattened.
915
+ Defaults to None.
916
+ ax : Optional[Axes]
917
+ An existing matplotlib Axes object to draw the device contour on. If None, a
918
+ new figure and axes will be created. Defaults to None.
919
+ **kwargs
920
+ Additional matplotlib parameters for plot customization.
921
+
922
+ Returns
923
+ -------
924
+ Axes
925
+ The matplotlib Axes object containing the contour plot. This can be used for
926
+ further customization or saving the plot after the method returns.
927
+ """
928
+ if level is None:
929
+ device_array = geometry.flatten(self.device_array)[:, :, 0]
930
+ else:
931
+ device_array = self.device_array[:, :, level]
932
+
933
+ kwargs.setdefault("cmap", "spring")
934
+ if linewidth is None:
935
+ linewidth = device_array.shape[0] // 100
936
+
937
+ contours, _ = cv2.findContours(
938
+ geometry.binarize_hard(device_array).astype(np.uint8),
939
+ cv2.RETR_CCOMP,
940
+ cv2.CHAIN_APPROX_SIMPLE,
941
+ )
942
+ contour_array = np.zeros_like(device_array, dtype=np.uint8)
943
+ cv2.drawContours(contour_array, contours, -1, (255,), linewidth)
944
+ contour_array = np.ma.masked_equal(contour_array, 0)
945
+
946
+ _, ax = self._plot_base(
947
+ plot_array=contour_array,
948
+ show_buffer=show_buffer,
949
+ bounds=bounds,
950
+ ax=ax,
951
+ **kwargs,
952
+ )
953
+ # cmap = cm.get_cmap(kwargs.get("cmap", "spring"))
954
+ # legend_proxy = Line2D([0], [0], linestyle="-", color=cmap(1))
955
+ # ax.legend([legend_proxy], [label], loc="upper right")
956
+ return ax
957
+
958
+ def plot_uncertainty(
959
+ self,
960
+ show_buffer: bool = True,
961
+ bounds: Optional[tuple[tuple[int, int], tuple[int, int]]] = None,
962
+ level: Optional[int] = None,
963
+ ax: Optional[Axes] = None,
964
+ **kwargs,
965
+ ):
966
+ """
967
+ Visualizes the uncertainty in the edge positions of the predicted device.
968
+
969
+ This method plots the uncertainty associated with the positions of the edges of
970
+ the device. The uncertainty is represented as a gradient, with areas of higher
971
+ uncertainty indicating a greater likelihood of the edge position from run to run
972
+ (due to inconsistencies in the fabrication process). This visualization can help
973
+ in identifying areas within the device geometry that may require design
974
+ adjustments to improve fabrication consistency.
975
+
976
+ Parameters
977
+ ----------
978
+ show_buffer : bool
979
+ If True, the buffer zones around the device will also be visualized. By
980
+ default, it is set to True.
981
+ bounds : Optional[tuple[tuple[int, int], tuple[int, int]]]
982
+ Specifies the bounds for zooming into the device geometry, formatted as
983
+ ((min_x, min_y), (max_x, max_y)). If 'max_x' or 'max_y' is set to "end", it
984
+ will be replaced with the corresponding dimension size of the device array.
985
+ If None, the entire device geometry is visualized.
986
+ level : int
987
+ The vertical layer to plot. If None, the device geometry is flattened.
988
+ Defaults to None.
989
+ ax : Optional[Axes]
990
+ An existing matplotlib Axes object to draw the uncertainty visualization on.
991
+ If None, a new figure and axes will be created. Defaults to None.
992
+ **kwargs
993
+ Additional matplotlib parameters for plot customization.
994
+
995
+ Returns
996
+ -------
997
+ Axes
998
+ The matplotlib Axes object containing the uncertainty visualization. This
999
+ can be used for further customization or saving the plot after the method
1000
+ returns.
1001
+ """
1002
+ uncertainty_array = self.get_uncertainty()
1003
+
1004
+ if level is None:
1005
+ uncertainty_array = geometry.flatten(uncertainty_array)[:, :, 0]
1006
+ else:
1007
+ uncertainty_array = uncertainty_array[:, :, level]
1008
+
1009
+ mappable, ax = self._plot_base(
1010
+ plot_array=uncertainty_array,
1011
+ show_buffer=show_buffer,
1012
+ bounds=bounds,
1013
+ ax=ax,
1014
+ **kwargs,
1015
+ )
1016
+ cbar = plt.colorbar(mappable, ax=ax)
1017
+ cbar.set_label("Uncertainty (a.u.)")
1018
+ return ax
1019
+
1020
+ def plot_compare(
1021
+ self,
1022
+ ref_device: "Device",
1023
+ show_buffer: bool = True,
1024
+ bounds: Optional[tuple[tuple[int, int], tuple[int, int]]] = None,
1025
+ level: Optional[int] = None,
1026
+ ax: Optional[Axes] = None,
1027
+ **kwargs,
1028
+ ) -> Axes:
1029
+ """
1030
+ Visualizes the comparison between the current device geometry and a reference
1031
+ device geometry.
1032
+
1033
+ Positive values (dilation) and negative values (erosion) are visualized with a
1034
+ color map to indicate areas where the current device has expanded or contracted
1035
+ relative to the reference.
1036
+
1037
+ Parameters
1038
+ ----------
1039
+ ref_device : Device
1040
+ The reference device to compare against.
1041
+ show_buffer : bool
1042
+ If True, visualizes the buffer zones around the device. Defaults to True.
1043
+ bounds : Optional[tuple[tuple[int, int], tuple[int, int]]]
1044
+ Specifies the bounds for zooming into the device geometry, formatted as
1045
+ ((min_x, min_y), (max_x, max_y)). If 'max_x' or 'max_y' is set to "end", it
1046
+ will be replaced with the corresponding dimension size of the device array.
1047
+ If None, the entire device geometry is visualized.
1048
+ level : int
1049
+ The vertical layer to plot. If None, the device geometry is flattened.
1050
+ Defaults to None.
1051
+ ax : Optional[Axes]
1052
+ An existing matplotlib Axes object to draw the comparison on. If None, a new
1053
+ figure and axes will be created. Defaults to None.
1054
+ **kwargs
1055
+ Additional matplotlib parameters for plot customization.
1056
+
1057
+ Returns
1058
+ -------
1059
+ Axes
1060
+ The matplotlib Axes object containing the comparison plot. This object can
1061
+ be used for further plot customization or saving the plot after the method
1062
+ returns.
1063
+ """
1064
+ plot_array = ref_device.device_array - self.device_array
1065
+
1066
+ if level is None:
1067
+ plot_array = geometry.flatten(plot_array)[:, :, 0]
1068
+ else:
1069
+ plot_array = plot_array[:, :, level]
1070
+
1071
+ mappable, ax = self._plot_base(
1072
+ plot_array=plot_array,
1073
+ show_buffer=show_buffer,
1074
+ bounds=bounds,
1075
+ ax=ax,
1076
+ cmap="jet",
1077
+ **kwargs,
1078
+ )
1079
+ cbar = plt.colorbar(mappable, ax=ax)
1080
+ cbar.set_label("Added (a.u.) Removed (a.u.)")
1081
+ return ax
1082
+
1083
+ def _add_buffer_visualization(self, ax: Axes):
1084
+ plot_array = self.device_array
1085
+
1086
+ buffer_thickness = self.buffer_spec.thickness
1087
+ buffer_fill = (0, 1, 0, 0.2)
1088
+ buffer_hatch = "/"
1089
+
1090
+ mid_rect = Rectangle(
1091
+ (buffer_thickness["left"], buffer_thickness["top"]),
1092
+ plot_array.shape[1] - buffer_thickness["left"] - buffer_thickness["right"],
1093
+ plot_array.shape[0] - buffer_thickness["top"] - buffer_thickness["bottom"],
1094
+ facecolor="none",
1095
+ edgecolor="black",
1096
+ linewidth=1,
1097
+ )
1098
+ ax.add_patch(mid_rect)
1099
+
1100
+ top_rect = Rectangle(
1101
+ (0, 0),
1102
+ plot_array.shape[1],
1103
+ buffer_thickness["top"],
1104
+ facecolor=buffer_fill,
1105
+ hatch=buffer_hatch,
1106
+ )
1107
+ ax.add_patch(top_rect)
1108
+
1109
+ bottom_rect = Rectangle(
1110
+ (0, plot_array.shape[0] - buffer_thickness["bottom"]),
1111
+ plot_array.shape[1],
1112
+ buffer_thickness["bottom"],
1113
+ facecolor=buffer_fill,
1114
+ hatch=buffer_hatch,
1115
+ )
1116
+ ax.add_patch(bottom_rect)
1117
+
1118
+ left_rect = Rectangle(
1119
+ (0, buffer_thickness["top"]),
1120
+ buffer_thickness["left"],
1121
+ plot_array.shape[0] - buffer_thickness["top"] - buffer_thickness["bottom"],
1122
+ facecolor=buffer_fill,
1123
+ hatch=buffer_hatch,
1124
+ )
1125
+ ax.add_patch(left_rect)
1126
+
1127
+ right_rect = Rectangle(
1128
+ (
1129
+ plot_array.shape[1] - buffer_thickness["right"],
1130
+ buffer_thickness["top"],
1131
+ ),
1132
+ buffer_thickness["right"],
1133
+ plot_array.shape[0] - buffer_thickness["top"] - buffer_thickness["bottom"],
1134
+ facecolor=buffer_fill,
1135
+ hatch=buffer_hatch,
1136
+ )
1137
+ ax.add_patch(right_rect)
1138
+
1139
+ def normalize(self) -> "Device":
1140
+ """
1141
+ Normalize the device geometry.
1142
+
1143
+ Returns
1144
+ -------
1145
+ Device
1146
+ A new instance of the Device with the normalized geometry.
1147
+ """
1148
+ normalized_device_array = geometry.normalize(device_array=self.device_array)
1149
+ return self.model_copy(update={"device_array": normalized_device_array})
1150
+
1151
+ def binarize(self, eta: float = 0.5, beta: float = np.inf) -> "Device":
1152
+ """
1153
+ Binarize the device geometry based on a threshold and a scaling factor.
1154
+
1155
+ Parameters
1156
+ ----------
1157
+ eta : float
1158
+ The threshold value for binarization. Defaults to 0.5.
1159
+ beta : float
1160
+ The scaling factor for the binarization process. A higher value makes the
1161
+ transition sharper. Defaults to np.inf, which results in a hard threshold.
1162
+
1163
+ Returns
1164
+ -------
1165
+ Device
1166
+ A new instance of the Device with the binarized geometry.
1167
+ """
1168
+ binarized_device_array = geometry.binarize(
1169
+ device_array=self.device_array, eta=eta, beta=beta
1170
+ )
1171
+ return self.model_copy(
1172
+ update={"device_array": binarized_device_array.astype(np.uint8)}
1173
+ )
1174
+
1175
+ def binarize_hard(self, eta: float = 0.5) -> "Device":
1176
+ """
1177
+ Apply a hard threshold to binarize the device geometry. The `binarize` function
1178
+ is generally preferred for most use cases, but it can create numerical artifacts
1179
+ for large beta values.
1180
+
1181
+ Parameters
1182
+ ----------
1183
+ eta : float
1184
+ The threshold value for binarization. Defaults to 0.5.
1185
+
1186
+ Returns
1187
+ -------
1188
+ Device
1189
+ A new instance of the Device with the threshold-binarized geometry.
1190
+ """
1191
+ binarized_device_array = geometry.binarize_hard(
1192
+ device_array=self.device_array, eta=eta
1193
+ )
1194
+ return self.model_copy(
1195
+ update={"device_array": binarized_device_array.astype(np.uint8)}
1196
+ )
1197
+
1198
+ def binarize_monte_carlo(
1199
+ self,
1200
+ noise_magnitude: float = 2.0,
1201
+ blur_radius: float = 8.0,
1202
+ ) -> "Device":
1203
+ """
1204
+ Binarize the input ndarray using a dynamic thresholding approach to simulate
1205
+ surfaceroughness.
1206
+
1207
+ This function applies a dynamic thresholding technique where the threshold value
1208
+ is determined by a base value perturbed by Gaussian-distributed random noise.
1209
+ Thethreshold is then spatially varied across the array using Gaussian blurring,
1210
+ simulating a potentially more realistic scenario where the threshold is not
1211
+ uniform across the device.
1212
+
1213
+ Notes
1214
+ -----
1215
+ This is a temporary solution, where the defaults are chosen based on what looks
1216
+ good. A better, data-driven approach is needed.
1217
+
1218
+ Parameters
1219
+ ----------
1220
+ noise_magnitude : float
1221
+ The standard deviation of the Gaussian distribution used to generate noise
1222
+ for the threshold values. This controls the amount of randomness in the
1223
+ threshold. Defaults to 2.0.
1224
+ blur_radius : float
1225
+ The standard deviation for the Gaussian kernel used in blurring the
1226
+ threshold map. This controls the spatial variation of the threshold across
1227
+ the array. Defaults to 9.0.
1228
+
1229
+ Returns
1230
+ -------
1231
+ Device
1232
+ A new instance of the Device with the binarized geometry.
1233
+ """
1234
+ binarized_device_array = geometry.binarize_monte_carlo(
1235
+ device_array=self.device_array,
1236
+ noise_magnitude=noise_magnitude,
1237
+ blur_radius=blur_radius,
1238
+ )
1239
+ return self.model_copy(update={"device_array": binarized_device_array})
1240
+
1241
+ def ternarize(self, eta1: float = 1 / 3, eta2: float = 2 / 3) -> "Device":
1242
+ """
1243
+ Ternarize the device geometry based on two thresholds. This function is useful
1244
+ for flattened devices with angled sidewalls (i.e., three segments).
1245
+
1246
+ Parameters
1247
+ ----------
1248
+ eta1 : float
1249
+ The first threshold value for ternarization. Defaults to 1/3.
1250
+ eta2 : float
1251
+ The second threshold value for ternarization. Defaults to 2/3.
1252
+
1253
+ Returns
1254
+ -------
1255
+ Device
1256
+ A new instance of the Device with the ternarized geometry.
1257
+ """
1258
+ ternarized_device_array = geometry.ternarize(
1259
+ device_array=self.flatten().device_array, eta1=eta1, eta2=eta2
1260
+ )
1261
+ return self.model_copy(update={"device_array": ternarized_device_array})
1262
+
1263
+ def trim(self) -> "Device":
1264
+ """
1265
+ Trim the device geometry by removing empty space around it.
1266
+
1267
+ Returns
1268
+ -------
1269
+ Device
1270
+ A new instance of the Device with the trimmed geometry.
1271
+ """
1272
+ trimmed_device_array = geometry.trim(
1273
+ device_array=self.device_array,
1274
+ buffer_thickness=self.buffer_spec.thickness,
1275
+ )
1276
+ return self.model_copy(update={"device_array": trimmed_device_array})
1277
+
1278
+ def pad(self, pad_width: int) -> "Device":
1279
+ """
1280
+ Pad the device geometry with a specified width on all sides.
1281
+ """
1282
+ padded_device_array = geometry.pad(
1283
+ device_array=self.device_array, pad_width=pad_width
1284
+ )
1285
+ return self.model_copy(update={"device_array": padded_device_array})
1286
+
1287
+ def blur(self, sigma: float = 1.0) -> "Device":
1288
+ """
1289
+ Apply Gaussian blur to the device geometry and normalize the result.
1290
+
1291
+ Parameters
1292
+ ----------
1293
+ sigma : float
1294
+ The standard deviation for the Gaussian kernel. This controls the amount of
1295
+ blurring. Defaults to 1.0.
1296
+
1297
+ Returns
1298
+ -------
1299
+ Device
1300
+ A new instance of the Device with the blurred and normalized geometry.
1301
+ """
1302
+ blurred_device_array = geometry.blur(
1303
+ device_array=self.device_array, sigma=sigma
1304
+ )
1305
+ return self.model_copy(update={"device_array": blurred_device_array})
1306
+
1307
+ def rotate(self, angle: float) -> "Device":
1308
+ """
1309
+ Rotate the device geometry by a given angle.
1310
+
1311
+ Parameters
1312
+ ----------
1313
+ angle : float
1314
+ The angle of rotation in degrees. Positive values mean counter-clockwise
1315
+ rotation.
1316
+
1317
+ Returns
1318
+ -------
1319
+ Device
1320
+ A new instance of the Device with the rotated geometry.
1321
+ """
1322
+ rotated_device_array = geometry.rotate(
1323
+ device_array=self.device_array, angle=angle
1324
+ )
1325
+ return self.model_copy(update={"device_array": rotated_device_array})
1326
+
1327
+ def erode(self, kernel_size: int = 3) -> "Device":
1328
+ """
1329
+ Erode the device geometry by removing small areas of overlap.
1330
+
1331
+ Parameters
1332
+ ----------
1333
+ kernel_size : int
1334
+ The size of the kernel used for erosion.
1335
+
1336
+ Returns
1337
+ -------
1338
+ Device
1339
+ A new instance of the Device with the eroded geometry.
1340
+ """
1341
+ eroded_device_array = geometry.erode(
1342
+ device_array=self.device_array, kernel_size=kernel_size
1343
+ )
1344
+ return self.model_copy(update={"device_array": eroded_device_array})
1345
+
1346
+ def dilate(self, kernel_size: int = 3) -> "Device":
1347
+ """
1348
+ Dilate the device geometry by expanding areas of overlap.
1349
+
1350
+ Parameters
1351
+ ----------
1352
+ kernel_size : int
1353
+ The size of the kernel used for dilation.
1354
+
1355
+ Returns
1356
+ -------
1357
+ Device
1358
+ A new instance of the Device with the dilated geometry.
1359
+ """
1360
+ dilated_device_array = geometry.dilate(
1361
+ device_array=self.device_array, kernel_size=kernel_size
1362
+ )
1363
+ return self.model_copy(update={"device_array": dilated_device_array})
1364
+
1365
+ def flatten(self) -> "Device":
1366
+ """
1367
+ Flatten the device geometry by summing the vertical layers and normalizing the
1368
+ result.
1369
+
1370
+ Returns
1371
+ -------
1372
+ Device
1373
+ A new instance of the Device with the flattened geometry.
1374
+ """
1375
+ flattened_device_array = geometry.flatten(device_array=self.device_array)
1376
+ return self.model_copy(update={"device_array": flattened_device_array})
1377
+
1378
+ def get_uncertainty(self) -> np.ndarray:
1379
+ """
1380
+ Calculate the uncertainty in the edge positions of the predicted device.
1381
+
1382
+ This method computes the uncertainty based on the deviation of the device's
1383
+ geometry values from the midpoint (0.5). The uncertainty is defined as the
1384
+ absolute difference from 0.5, scaled and inverted to provide a measure where
1385
+ higher values indicate greater uncertainty.
1386
+
1387
+ Returns
1388
+ -------
1389
+ np.ndarray
1390
+ An array representing the uncertainty in the edge positions of the device,
1391
+ with higher values indicating greater uncertainty.
1392
+ """
1393
+ return 1 - 2 * np.abs(0.5 - self.device_array)
1394
+
1395
+ def enforce_feature_size(
1396
+ self, min_feature_size: int, strel: str = "disk"
1397
+ ) -> "Device":
1398
+ """
1399
+ Enforce a minimum feature size on the device geometry.
1400
+
1401
+ This method applies morphological operations to ensure that all features in the
1402
+ device geometry are at least the specified minimum size. It uses either a disk
1403
+ or square structuring element for the operations.
1404
+
1405
+ Notes
1406
+ -----
1407
+ This function does not guarantee that the minimum feature size is enforced in
1408
+ all cases. A better process is needed.
1409
+
1410
+ Parameters
1411
+ ----------
1412
+ min_feature_size : int
1413
+ The minimum feature size to enforce, in nanometers.
1414
+ strel : str
1415
+ The type of structuring element to use. Can be either "disk" or "square".
1416
+ Defaults to "disk".
1417
+
1418
+ Returns
1419
+ -------
1420
+ Device
1421
+ A new instance of the Device with the modified geometry.
1422
+
1423
+ Raises
1424
+ ------
1425
+ ValueError
1426
+ If an invalid structuring element type is specified.
1427
+ """
1428
+ modified_geometry = geometry.enforce_feature_size(
1429
+ device_array=self.device_array,
1430
+ min_feature_size=min_feature_size,
1431
+ strel=strel,
1432
+ )
1433
+ return self.model_copy(update={"device_array": modified_geometry})
1434
+
1435
+ def check_feature_size(self, min_feature_size: int, strel: str = "disk"):
1436
+ """
1437
+ Check and visualize the effect of enforcing a minimum feature size on the device
1438
+ geometry.
1439
+
1440
+ This method enforces a minimum feature size on the device geometry using the
1441
+ specified structuring element, compares the modified geometry with the original,
1442
+ and plots the differences. It also calculates and prints the Hamming distance
1443
+ between the original and modified geometries, providing a measure of the changes
1444
+ introduced by the feature size enforcement.
1445
+
1446
+ Notes
1447
+ -----
1448
+ This is not a design-rule-checking function, but it can be useful for quick
1449
+ checks.
1450
+
1451
+ Parameters
1452
+ ----------
1453
+ min_feature_size : int
1454
+ The minimum feature size to enforce, in nanometers.
1455
+ strel : str
1456
+ The type of structuring element to use. Can be either "disk" or "square".
1457
+ Defaults to "disk".
1458
+
1459
+ Raises
1460
+ ------
1461
+ ValueError
1462
+ If an invalid structuring element type is specified or if min_feature_size
1463
+ is not a positive integer.
1464
+ """
1465
+ if min_feature_size <= 0:
1466
+ raise ValueError("min_feature_size must be a positive integer.")
1467
+
1468
+ enforced_device = self.enforce_feature_size(min_feature_size, strel)
1469
+
1470
+ difference = np.abs(
1471
+ enforced_device.device_array[:, :, 0] - self.device_array[:, :, 0]
1472
+ )
1473
+ _, ax = self._plot_base(
1474
+ plot_array=difference,
1475
+ show_buffer=False,
1476
+ ax=None,
1477
+ bounds=None,
1478
+ cmap="jet",
1479
+ )
1480
+
1481
+ hamming_distance = compare.hamming_distance(self, enforced_device)
1482
+ print(
1483
+ f"Feature size check with minimum size {min_feature_size} "
1484
+ f"using '{strel}' structuring element resulted in a Hamming "
1485
+ f"distance of: {hamming_distance}"
1486
+ )