prefab 0.5.2__py3-none-any.whl → 1.0.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.
prefab/device.py ADDED
@@ -0,0 +1,1233 @@
1
+ """Provides the Device class for representing photonic devices."""
2
+
3
+ import base64
4
+ import io
5
+ import json
6
+ import os
7
+ from typing import Optional
8
+
9
+ import cv2
10
+ import gdstk
11
+ import matplotlib.pyplot as plt
12
+ import numpy as np
13
+ import requests
14
+ import toml
15
+ from matplotlib.axes import Axes
16
+ from matplotlib.patches import Rectangle
17
+ from PIL import Image
18
+ from pydantic import BaseModel, Field, conint, root_validator, validator
19
+ from tqdm import tqdm
20
+
21
+ from . import geometry
22
+ from .models import Model
23
+
24
+
25
+ class BufferSpec(BaseModel):
26
+ """
27
+ Defines the specifications for a buffer zone around a device.
28
+
29
+ This class is used to specify the mode and thickness of a buffer zone that is added
30
+ around the device geometry. The buffer zone can be used for various purposes such as
31
+ providing extra space for device fabrication processes or for ensuring that the
32
+ device is isolated from surrounding structures.
33
+
34
+ Parameters
35
+ ----------
36
+ mode : dict[str, str]
37
+ A dictionary that defines the buffer mode for each side of the device
38
+ ('top', 'bottom', 'left', 'right'), where 'constant' is used for isolated
39
+ structures and 'edge' is utilized for preserving the edge, such as for waveguide
40
+ connections.
41
+ thickness : conint(gt=0)
42
+ The thickness of the buffer zone around the device. Must be greater than 0.
43
+
44
+ Raises
45
+ ------
46
+ ValueError
47
+ If any of the modes specified in the 'mode' dictionary are not one of the
48
+ allowed values ('constant', 'edge'). Or if the thickness is not greater than 0.
49
+
50
+ Example
51
+ -------
52
+ import prefab as pf
53
+
54
+ buffer_spec = pf.BufferSpec(
55
+ mode={
56
+ "top": "constant",
57
+ "bottom": "edge",
58
+ "left": "constant",
59
+ "right": "edge",
60
+ },
61
+ thickness=150,
62
+ )
63
+ """
64
+
65
+ mode: dict[str, str] = Field(
66
+ default_factory=lambda: {
67
+ "top": "constant",
68
+ "bottom": "constant",
69
+ "left": "constant",
70
+ "right": "constant",
71
+ }
72
+ )
73
+ thickness: conint(gt=0) = 128
74
+
75
+ @validator("mode", pre=True)
76
+ def check_mode(cls, v):
77
+ allowed_modes = ["constant", "edge"]
78
+ if not all(mode in allowed_modes for mode in v.values()):
79
+ raise ValueError(f"Buffer mode must be one of {allowed_modes}, got '{v}'")
80
+ return v
81
+
82
+
83
+ class Device(BaseModel):
84
+ device_array: np.ndarray = Field(...)
85
+ buffer_spec: BufferSpec = Field(default_factory=BufferSpec)
86
+
87
+ class Config:
88
+ arbitrary_types_allowed = True
89
+
90
+ @property
91
+ def shape(self) -> tuple[int, int]:
92
+ return self.device_array.shape
93
+
94
+ def __init__(
95
+ self, device_array: np.ndarray, buffer_spec: Optional[BufferSpec] = None
96
+ ):
97
+ """
98
+ Represents the planar geometry of a photonic device design that will have its
99
+ nanofabrication outcome predicted and/or corrected.
100
+
101
+ This class is designed to encapsulate the geometric representation of a photonic
102
+ device, facilitating operations such as padding, normalization, binarization,
103
+ ternarization, trimming, and blurring. These operations are useful for preparing
104
+ the device design for prediction or correction. Additionally, the class provides
105
+ methods for exporting the device representation to various formats, including
106
+ ndarray, image files, and GDSII files, supporting a range of analysis and
107
+ fabrication workflows.
108
+
109
+ Parameters
110
+ ----------
111
+ device_array : np.ndarray
112
+ A 2D array representing the planar geometry of the device. This array
113
+ undergoes various transformations to predict or correct the nanofabrication
114
+ process.
115
+ buffer_spec : BufferSpec, optional
116
+ Defines the parameters for adding a buffer zone around the device geometry.
117
+ This buffer zone is needed for providing surrounding context for prediction
118
+ or correction and for ensuring seamless integration with the surrounding
119
+ circuitry. By default, a generous padding is applied to accommodate isolated
120
+ structures.
121
+
122
+ Attributes
123
+ ----------
124
+ shape : tuple[int, int]
125
+ The shape of the device array.
126
+
127
+ Raises
128
+ ------
129
+ ValueError
130
+ If the provided `device_array` is not a numpy ndarray or is not a 2D array,
131
+ indicating an invalid device geometry.
132
+ """
133
+ super().__init__(
134
+ device_array=device_array, buffer_spec=buffer_spec or BufferSpec()
135
+ )
136
+ self._initial_processing()
137
+
138
+ def __call__(self, *args, **kwargs):
139
+ return self.plot(*args, **kwargs)
140
+
141
+ def _initial_processing(self):
142
+ buffer_thickness = self.buffer_spec.thickness
143
+ buffer_mode = self.buffer_spec.mode
144
+
145
+ self.device_array = np.pad(
146
+ self.device_array,
147
+ pad_width=((buffer_thickness, 0), (0, 0)),
148
+ mode=buffer_mode["top"],
149
+ )
150
+ self.device_array = np.pad(
151
+ self.device_array,
152
+ pad_width=((0, buffer_thickness), (0, 0)),
153
+ mode=buffer_mode["bottom"],
154
+ )
155
+ self.device_array = np.pad(
156
+ self.device_array,
157
+ pad_width=((0, 0), (buffer_thickness, 0)),
158
+ mode=buffer_mode["left"],
159
+ )
160
+ self.device_array = np.pad(
161
+ self.device_array,
162
+ pad_width=((0, 0), (0, buffer_thickness)),
163
+ mode=buffer_mode["right"],
164
+ )
165
+
166
+ self.device_array = np.expand_dims(
167
+ self.device_array.astype(np.float32), axis=-1
168
+ )
169
+
170
+ @root_validator(pre=True)
171
+ def check_device_array(cls, values):
172
+ device_array = values.get("device_array")
173
+ if not isinstance(device_array, np.ndarray):
174
+ raise ValueError("device_array must be a numpy ndarray.")
175
+ if device_array.ndim != 2:
176
+ raise ValueError("device_array must be a 2D array.")
177
+ return values
178
+
179
+ def is_binary(self) -> bool:
180
+ """
181
+ Check if the device geometry is binary.
182
+
183
+ Returns
184
+ -------
185
+ bool
186
+ True if the device geometry is binary, False otherwise.
187
+ """
188
+ unique_values = np.unique(self.device_array)
189
+ return (
190
+ np.array_equal(unique_values, [0, 1])
191
+ or np.array_equal(unique_values, [1, 0])
192
+ or np.array_equal(unique_values, [0])
193
+ or np.array_equal(unique_values, [1])
194
+ )
195
+
196
+ def _encode_array(self, array):
197
+ image = Image.fromarray(np.uint8(array * 255))
198
+ buffered = io.BytesIO()
199
+ image.save(buffered, format="PNG")
200
+ encoded_png = base64.b64encode(buffered.getvalue()).decode("utf-8")
201
+ return encoded_png
202
+
203
+ def _decode_array(self, encoded_png):
204
+ binary_data = base64.b64decode(encoded_png)
205
+ image = Image.open(io.BytesIO(binary_data))
206
+ return np.array(image) / 255
207
+
208
+ def _predict_array(
209
+ self,
210
+ model: Model,
211
+ model_type: str,
212
+ binarize: bool,
213
+ ) -> "Device":
214
+ try:
215
+ with open(os.path.expanduser("~/.prefab.toml")) as file:
216
+ content = file.readlines()
217
+ access_token = None
218
+ refresh_token = None
219
+ for line in content:
220
+ if "access_token" in line:
221
+ access_token = line.split("=")[1].strip().strip('"')
222
+ if "refresh_token" in line:
223
+ refresh_token = line.split("=")[1].strip().strip('"')
224
+ break
225
+ if not access_token or not refresh_token:
226
+ raise ValueError("Token not found in the configuration file.")
227
+ except FileNotFoundError:
228
+ raise FileNotFoundError(
229
+ "Could not validate user.\n"
230
+ "Please update prefab using: pip install --upgrade prefab.\n"
231
+ "Signup/login and generate a new token.\n"
232
+ "See https://www.prefabphotonics.com/docs/guides/quickstart."
233
+ ) from None
234
+ headers = {
235
+ "Authorization": f"Bearer {access_token}",
236
+ "X-Refresh-Token": refresh_token,
237
+ }
238
+
239
+ predict_data = {
240
+ "device_array": self._encode_array(self.device_array[:, :, 0]),
241
+ "model": model.to_json(),
242
+ "model_type": model_type,
243
+ "binary": binarize,
244
+ }
245
+ json_data = json.dumps(predict_data)
246
+
247
+ endpoint_url = "https://prefab-photonics--predict-v1.modal.run"
248
+
249
+ with requests.post(
250
+ endpoint_url, data=json_data, headers=headers, stream=True
251
+ ) as response:
252
+ response.raise_for_status()
253
+ event_type = None
254
+ model_descriptions = {"p": "Prediction", "c": "Correction", "s": "SEMulate"}
255
+ progress_bar = tqdm(
256
+ total=100,
257
+ desc=f"{model_descriptions[model_type]}",
258
+ unit="%",
259
+ colour="green",
260
+ bar_format="{l_bar}{bar:30}{r_bar}{bar:-10b}",
261
+ )
262
+
263
+ for line in response.iter_lines():
264
+ if line:
265
+ decoded_line = line.decode("utf-8").strip()
266
+ if decoded_line.startswith("event:"):
267
+ event_type = decoded_line.split(":")[1].strip()
268
+ elif decoded_line.startswith("data:"):
269
+ try:
270
+ data_content = json.loads(decoded_line.split("data: ")[1])
271
+ if event_type == "progress":
272
+ progress = round(100 * data_content["progress"])
273
+ progress_bar.update(progress - progress_bar.n)
274
+ elif event_type == "result":
275
+ results = []
276
+ for key in sorted(data_content.keys()):
277
+ if key.startswith("result"):
278
+ decoded_image = self._decode_array(
279
+ data_content[key]
280
+ )
281
+ results.append(decoded_image)
282
+
283
+ if results:
284
+ prediction = np.stack(results, axis=-1)
285
+ if binarize:
286
+ prediction = geometry.binarize_hard(prediction)
287
+ progress_bar.close()
288
+ return prediction
289
+ elif event_type == "end":
290
+ print("Stream ended.")
291
+ progress_bar.close()
292
+ break
293
+ elif event_type == "auth":
294
+ if "new_refresh_token" in data_content["auth"]:
295
+ prefab_file_path = os.path.expanduser(
296
+ "~/.prefab.toml"
297
+ )
298
+ with open(
299
+ prefab_file_path, "w", encoding="utf-8"
300
+ ) as toml_file:
301
+ toml.dump(
302
+ {
303
+ "access_token": data_content["auth"][
304
+ "new_access_token"
305
+ ],
306
+ "refresh_token": data_content["auth"][
307
+ "new_refresh_token"
308
+ ],
309
+ },
310
+ toml_file,
311
+ )
312
+ elif event_type == "error":
313
+ print(f"Error: {data_content['error']}")
314
+ progress_bar.close()
315
+ except json.JSONDecodeError:
316
+ print(
317
+ "Failed to decode JSON:",
318
+ decoded_line.split("data: ")[1],
319
+ )
320
+
321
+ def predict(
322
+ self,
323
+ model: Model,
324
+ binarize: bool = False,
325
+ ) -> "Device":
326
+ """
327
+ Predict the nanofabrication outcome of the device using a specified model.
328
+
329
+ This method sends the device geometry to a serverless prediction service, which
330
+ uses a specified machine learning model to predict the outcome of the
331
+ nanofabrication process.
332
+
333
+ Parameters
334
+ ----------
335
+ model : Model
336
+ The model to use for prediction, representing a specific fabrication process
337
+ and dataset. This model encapsulates details about the fabrication foundry,
338
+ process, material, technology, thickness, and sidewall presence, as defined
339
+ in `models.py`. Each model is associated with a version and dataset that
340
+ detail its creation and the data it was trained on, ensuring the prediction
341
+ is tailored to specific fabrication parameters.
342
+ binarize : bool, optional
343
+ If True, the predicted device geometry will be binarized using a threshold
344
+ method. This is useful for converting probabilistic predictions into binary
345
+ geometries. Defaults to False.
346
+
347
+ Returns
348
+ -------
349
+ Device
350
+ A new instance of the Device class with the predicted geometry.
351
+
352
+ Raises
353
+ ------
354
+ ValueError
355
+ If the prediction service returns an error or if the response from the
356
+ service cannot be processed correctly.
357
+ """
358
+ prediction_array = self._predict_array(
359
+ model=model,
360
+ model_type="p",
361
+ binarize=binarize,
362
+ )
363
+ return self.model_copy(update={"device_array": prediction_array})
364
+
365
+ def correct(
366
+ self,
367
+ model: Model,
368
+ binarize: bool = True,
369
+ ) -> "Device":
370
+ """
371
+ Correct the nanofabrication outcome of the device using a specified model.
372
+
373
+ This method sends the device geometry to a serverless correction service, which
374
+ uses a specified machine learning model to correct the outcome of the
375
+ nanofabrication process. The correction aims to adjust the device geometry to
376
+ compensate for known fabrication errors and improve the accuracy of the final
377
+ device structure.
378
+
379
+ Parameters
380
+ ----------
381
+ model : Model
382
+ The model to use for correction, representing a specific fabrication process
383
+ and dataset. This model encapsulates details about the fabrication foundry,
384
+ process, material, technology, thickness, and sidewall presence, as defined
385
+ in `models.py`. Each model is associated with a version and dataset that
386
+ detail its creation and the data it was trained on, ensuring the correction
387
+ is tailored to specific fabrication parameters.
388
+ binarize : bool, optional
389
+ If True, the corrected device geometry will be binarized using a threshold
390
+ method. This is useful for converting probabilistic corrections into binary
391
+ geometries. Defaults to True.
392
+
393
+ Returns
394
+ -------
395
+ Device
396
+ A new instance of the Device class with the corrected geometry.
397
+
398
+ Raises
399
+ ------
400
+ ValueError
401
+ If the correction service returns an error or if the response from the
402
+ service cannot be processed correctly.
403
+ """
404
+ correction_array = self._predict_array(
405
+ model=model,
406
+ model_type="c",
407
+ binarize=binarize,
408
+ )
409
+ return self.model_copy(update={"device_array": correction_array})
410
+
411
+ def semulate(
412
+ self,
413
+ model: Model,
414
+ ) -> "Device":
415
+ """
416
+ Simulate the appearance of the device as if viewed under a scanning electron
417
+ microscope (SEM).
418
+
419
+ This method applies a specified machine learning model to transform the device
420
+ geometry into a style that resembles an SEM image. This can be useful for
421
+ visualizing how the device might appear under an SEM, which is often used for
422
+ inspecting the surface and composition of materials at high magnification.
423
+
424
+ Parameters
425
+ ----------
426
+ model : Model
427
+ The model to use for SEMulation, representing a specific fabrication process
428
+ and dataset. This model encapsulates details about the fabrication foundry,
429
+ process, material, technology, thickness, and sidewall presence, as defined
430
+ in `models.py`. Each model is associated with a version and dataset that
431
+ detail its creation and the data it was trained on, ensuring the SEMulation
432
+ is tailored to specific fabrication parameters.
433
+
434
+ Returns
435
+ -------
436
+ Device
437
+ A new instance of the Device class with its geometry transformed to simulate
438
+ an SEM image style.
439
+ """
440
+ semulated_array = self._predict_array(
441
+ model=model,
442
+ model_type="s",
443
+ binarize=False,
444
+ )
445
+ return self.model_copy(update={"device_array": semulated_array})
446
+
447
+ def to_ndarray(self) -> np.ndarray:
448
+ """
449
+ Converts the device geometry to an ndarray.
450
+
451
+ This method applies the buffer specifications to crop the device array if
452
+ necessary, based on the buffer mode ('edge' or 'constant'). It then returns the
453
+ resulting ndarray representing the device geometry.
454
+
455
+ Returns
456
+ -------
457
+ np.ndarray
458
+ The ndarray representation of the device geometry, with any applied buffer
459
+ cropping.
460
+ """
461
+ device_array = np.copy(self.device_array)
462
+ buffer_thickness = self.buffer_spec.thickness
463
+ buffer_mode = self.buffer_spec.mode
464
+
465
+ crop_top = buffer_thickness if buffer_mode["top"] == "edge" else 0
466
+ crop_bottom = buffer_thickness if buffer_mode["bottom"] == "edge" else 0
467
+ crop_left = buffer_thickness if buffer_mode["left"] == "edge" else 0
468
+ crop_right = buffer_thickness if buffer_mode["right"] == "edge" else 0
469
+
470
+ ndarray = device_array[
471
+ crop_top : device_array.shape[0] - crop_bottom,
472
+ crop_left : device_array.shape[1] - crop_right,
473
+ ]
474
+ return np.squeeze(ndarray)
475
+
476
+ def to_img(self, img_path: str = "prefab_device.png"):
477
+ """
478
+ Exports the device geometry as an image file.
479
+
480
+ This method converts the device geometry to an ndarray using `to_ndarray`,
481
+ scales the values to the range [0, 255] for image representation, and saves the
482
+ result as an image file.
483
+
484
+ Parameters
485
+ ----------
486
+ img_path : str, optional
487
+ The path where the image file will be saved. If not specified, the image is
488
+ saved as "prefab_device.png" in the current directory.
489
+ """
490
+ cv2.imwrite(img_path, 255 * self.flatten().to_ndarray())
491
+ print(f"Saved Device to '{img_path}'")
492
+
493
+ def to_gds(
494
+ self,
495
+ gds_path: str = "prefab_device.gds",
496
+ cell_name: str = "prefab_device",
497
+ gds_layer: tuple[int, int] = (1, 0),
498
+ contour_approx_mode: int = 2,
499
+ ):
500
+ """
501
+ Exports the device geometry as a GDSII file.
502
+
503
+ This method converts the device geometry into a format suitable for GDSII files.
504
+ The conversion involves contour approximation to simplify the geometry while
505
+ preserving essential features.
506
+
507
+ Parameters
508
+ ----------
509
+ gds_path : str, optional
510
+ The path where the GDSII file will be saved. If not specified, the file is
511
+ saved as "prefab_device.gds" in the current directory.
512
+ cell_name : str, optional
513
+ The name of the cell within the GDSII file. If not specified, defaults to
514
+ "prefab_device".
515
+ gds_layer : tuple[int, int], optional
516
+ The layer and datatype to use within the GDSII file. Defaults to (1, 0).
517
+ contour_approx_mode : int, optional
518
+ The mode of contour approximation used during the conversion. Defaults to 2,
519
+ which corresponds to `cv2.CHAIN_APPROX_SIMPLE`, a method that compresses
520
+ horizontal, vertical, and diagonal segments and leaves only their endpoints.
521
+ """
522
+ gdstk_cell = self.flatten()._device_to_gdstk(
523
+ cell_name=cell_name,
524
+ gds_layer=gds_layer,
525
+ contour_approx_mode=contour_approx_mode,
526
+ )
527
+ print(f"Saving GDS to '{gds_path}'...")
528
+ gdstk_library = gdstk.Library()
529
+ gdstk_library.add(gdstk_cell)
530
+ gdstk_library.write_gds(outfile=gds_path, max_points=8190)
531
+
532
+ def to_gdstk(
533
+ self,
534
+ cell_name: str = "prefab_device",
535
+ gds_layer: tuple[int, int] = (1, 0),
536
+ contour_approx_mode: int = 2,
537
+ ):
538
+ """
539
+ Converts the device geometry to a GDSTK cell object.
540
+
541
+ This method prepares the device geometry for GDSII file export by converting it
542
+ into a GDSTK cell object. GDSTK is a Python module for creating and manipulating
543
+ GDSII layout files. The conversion involves contour approximation to simplify
544
+ the geometry while preserving essential features.
545
+
546
+ Parameters
547
+ ----------
548
+ cell_name : str, optional
549
+ The name of the cell to be created. Defaults to "prefab_device".
550
+ gds_layer : tuple[int, int], optional
551
+ The layer and datatype to use within the GDSTK cell. Defaults to (1, 0).
552
+ contour_approx_mode : int, optional
553
+ The mode of contour approximation used during the conversion. Defaults to 2,
554
+ which corresponds to `cv2.CHAIN_APPROX_SIMPLE`, a method that compresses
555
+ horizontal, vertical, and diagonal segments and leaves only their endpoints.
556
+
557
+ Returns
558
+ -------
559
+ gdstk.Cell
560
+ The GDSTK cell object representing the device geometry.
561
+ """
562
+ print(f"Creating cell '{cell_name}'...")
563
+ gdstk_cell = self.flatten()._device_to_gdstk(
564
+ cell_name=cell_name,
565
+ gds_layer=gds_layer,
566
+ contour_approx_mode=contour_approx_mode,
567
+ )
568
+ return gdstk_cell
569
+
570
+ def _device_to_gdstk(
571
+ self,
572
+ cell_name: str,
573
+ gds_layer: tuple[int, int],
574
+ contour_approx_mode: int,
575
+ ) -> gdstk.Cell:
576
+ approx_mode_mapping = {
577
+ 1: cv2.CHAIN_APPROX_NONE,
578
+ 2: cv2.CHAIN_APPROX_SIMPLE,
579
+ 3: cv2.CHAIN_APPROX_TC89_L1,
580
+ 4: cv2.CHAIN_APPROX_TC89_KCOS,
581
+ }
582
+
583
+ contours, hierarchy = cv2.findContours(
584
+ np.flipud(self.to_ndarray()).astype(np.uint8),
585
+ cv2.RETR_TREE,
586
+ approx_mode_mapping[contour_approx_mode],
587
+ )
588
+
589
+ hierarchy_polygons = {}
590
+ for idx, contour in enumerate(contours):
591
+ level = 0
592
+ current_idx = idx
593
+ while hierarchy[0][current_idx][3] != -1:
594
+ level += 1
595
+ current_idx = hierarchy[0][current_idx][3]
596
+
597
+ if len(contour) > 2:
598
+ contour = contour / 1000
599
+ points = [tuple(point) for point in contour.squeeze().tolist()]
600
+ if level not in hierarchy_polygons:
601
+ hierarchy_polygons[level] = []
602
+ hierarchy_polygons[level].append(points)
603
+
604
+ cell = gdstk.Cell(cell_name)
605
+ processed_polygons = []
606
+ for level in sorted(hierarchy_polygons.keys()):
607
+ operation = "or" if level % 2 == 0 else "xor"
608
+ polygons_to_process = hierarchy_polygons[level]
609
+
610
+ if polygons_to_process:
611
+ processed_polygons = gdstk.boolean(
612
+ polygons_to_process,
613
+ processed_polygons,
614
+ operation,
615
+ layer=gds_layer[0],
616
+ datatype=gds_layer[1],
617
+ )
618
+ for polygon in processed_polygons:
619
+ cell.add(polygon)
620
+
621
+ return cell
622
+
623
+ def _plot_base(
624
+ self,
625
+ plot_array: np.ndarray,
626
+ show_buffer: bool,
627
+ bounds: Optional[tuple[tuple[int, int], tuple[int, int]]],
628
+ ax: Optional[Axes],
629
+ **kwargs,
630
+ ) -> Axes:
631
+ if ax is None:
632
+ _, ax = plt.subplots()
633
+ ax.set_ylabel("y (nm)")
634
+ ax.set_xlabel("x (nm)")
635
+
636
+ min_x, min_y = (0, 0) if bounds is None else bounds[0]
637
+ max_x, max_y = plot_array.shape[::-1] if bounds is None else bounds[1]
638
+ min_x = max(min_x, 0)
639
+ min_y = max(min_y, 0)
640
+ max_x = "end" if max_x == "end" else min(max_x, plot_array.shape[1])
641
+ max_y = "end" if max_y == "end" else min(max_y, plot_array.shape[0])
642
+ max_x = plot_array.shape[1] if max_x == "end" else max_x
643
+ max_y = plot_array.shape[0] if max_y == "end" else max_y
644
+ plot_array = plot_array[
645
+ plot_array.shape[0] - max_y : plot_array.shape[0] - min_y,
646
+ min_x:max_x,
647
+ ]
648
+ extent = [min_x, max_x, min_y, max_y]
649
+
650
+ if not np.ma.is_masked(plot_array):
651
+ max_size = (1000, 1000)
652
+ scale_x = min(1, max_size[0] / plot_array.shape[1])
653
+ scale_y = min(1, max_size[1] / plot_array.shape[0])
654
+ fx = min(scale_x, scale_y)
655
+ fy = fx
656
+
657
+ plot_array = cv2.resize(
658
+ plot_array,
659
+ dsize=(0, 0),
660
+ fx=fx,
661
+ fy=fy,
662
+ interpolation=cv2.INTER_NEAREST,
663
+ )
664
+
665
+ mappable = ax.imshow(
666
+ plot_array,
667
+ extent=extent,
668
+ **kwargs,
669
+ )
670
+
671
+ if show_buffer:
672
+ self._add_buffer_visualization(ax)
673
+
674
+ return mappable, ax
675
+
676
+ def plot(
677
+ self,
678
+ show_buffer: bool = True,
679
+ bounds: Optional[tuple[tuple[int, int], tuple[int, int]]] = None,
680
+ level: int = None,
681
+ ax: Optional[Axes] = None,
682
+ **kwargs,
683
+ ) -> Axes:
684
+ """
685
+ Visualizes the device geometry.
686
+
687
+ This method allows for the visualization of the device geometry. The
688
+ visualization can be customized with various matplotlib parameters and can be
689
+ drawn on an existing matplotlib Axes object or create a new one if none is
690
+ provided.
691
+
692
+ Parameters
693
+ ----------
694
+ show_buffer : bool, optional
695
+ If True, visualizes the buffer zones around the device. Defaults to True.
696
+ bounds : Optional[tuple[tuple[int, int], tuple[int, int]]], optional
697
+ Specifies the bounds for zooming into the device geometry, formatted as
698
+ ((min_x, min_y), (max_x, max_y)). If 'max_x' or 'max_y' is set to "end", it
699
+ will be replaced with the corresponding dimension size of the device array.
700
+ If None, the entire device geometry is visualized.
701
+ level : int, optional
702
+ The vertical layer to plot. If None, the device geometry is flattened.
703
+ Defaults to None.
704
+ ax : Optional[Axes], optional
705
+ An existing matplotlib Axes object to draw the device geometry on. If
706
+ None, a new figure and axes will be created. Defaults to None.
707
+ **kwargs
708
+ Additional matplotlib parameters for plot customization.
709
+
710
+ Returns
711
+ -------
712
+ Axes
713
+ The matplotlib Axes object containing the plot. This object can be used for
714
+ further plot customization or saving the plot after the method returns.
715
+ """
716
+ if level is None:
717
+ plot_array = geometry.flatten(self.device_array)[:, :, 0]
718
+ else:
719
+ plot_array = self.device_array[:, :, level]
720
+ _, ax = self._plot_base(
721
+ plot_array=plot_array,
722
+ show_buffer=show_buffer,
723
+ bounds=bounds,
724
+ ax=ax,
725
+ **kwargs,
726
+ )
727
+ return ax
728
+
729
+ def plot_contour(
730
+ self,
731
+ linewidth: Optional[int] = None,
732
+ # label: Optional[str] = "Device contour",
733
+ show_buffer: bool = True,
734
+ bounds: Optional[tuple[tuple[int, int], tuple[int, int]]] = None,
735
+ level: int = None,
736
+ ax: Optional[Axes] = None,
737
+ **kwargs,
738
+ ):
739
+ """
740
+ Visualizes the contour of the device geometry.
741
+
742
+ This method plots the contour of the device geometry, emphasizing the edges and
743
+ boundaries of the device. The contour plot can be customized with various
744
+ matplotlib parameters, including line width and color. The plot can be drawn on
745
+ an existing matplotlib Axes object or create a new one if none is provided.
746
+
747
+ Parameters
748
+ ----------
749
+ linewidth : Optional[int], optional
750
+ The width of the contour lines. If None, the linewidth is automatically
751
+ determined based on the size of the device array. Defaults to None.
752
+ show_buffer : bool, optional
753
+ If True, the buffer zones around the device will be visualized. By default,
754
+ it is set to True.
755
+ bounds : Optional[tuple[tuple[int, int], tuple[int, int]]], optional
756
+ Specifies the bounds for zooming into the device geometry, formatted as
757
+ ((min_x, min_y), (max_x, max_y)). If 'max_x' or 'max_y' is set to "end", it
758
+ will be replaced with the corresponding dimension size of the device array.
759
+ If None, the entire device geometry is visualized.
760
+ level : int, optional
761
+ The vertical layer to plot. If None, the device geometry is flattened.
762
+ Defaults to None.
763
+ ax : Optional[Axes], optional
764
+ An existing matplotlib Axes object to draw the device contour on. If None, a
765
+ new figure and axes will be created. Defaults to None.
766
+ **kwargs
767
+ Additional matplotlib parameters for plot customization.
768
+
769
+ Returns
770
+ -------
771
+ Axes
772
+ The matplotlib Axes object containing the contour plot. This can be used for
773
+ further customization or saving the plot after the method returns.
774
+ """
775
+ if level is None:
776
+ device_array = geometry.flatten(self.device_array)[:, :, 0]
777
+ else:
778
+ device_array = self.device_array[:, :, level]
779
+
780
+ kwargs.setdefault("cmap", "spring")
781
+ if linewidth is None:
782
+ linewidth = device_array.shape[0] // 100
783
+
784
+ contours, _ = cv2.findContours(
785
+ geometry.binarize_hard(device_array).astype(np.uint8),
786
+ cv2.RETR_CCOMP,
787
+ cv2.CHAIN_APPROX_SIMPLE,
788
+ )
789
+ contour_array = np.zeros_like(device_array, dtype=np.uint8)
790
+ cv2.drawContours(contour_array, contours, -1, (255,), linewidth)
791
+ contour_array = np.ma.masked_equal(contour_array, 0)
792
+
793
+ _, ax = self._plot_base(
794
+ plot_array=contour_array,
795
+ show_buffer=show_buffer,
796
+ bounds=bounds,
797
+ ax=ax,
798
+ **kwargs,
799
+ )
800
+ # cmap = cm.get_cmap(kwargs.get("cmap", "spring"))
801
+ # legend_proxy = Line2D([0], [0], linestyle="-", color=cmap(1))
802
+ # ax.legend([legend_proxy], [label], loc="upper right")
803
+ return ax
804
+
805
+ def plot_uncertainty(
806
+ self,
807
+ show_buffer: bool = True,
808
+ bounds: Optional[tuple[tuple[int, int], tuple[int, int]]] = None,
809
+ level: int = None,
810
+ ax: Optional[Axes] = None,
811
+ **kwargs,
812
+ ):
813
+ """
814
+ Visualizes the uncertainty in the edge positions of the predicted device.
815
+
816
+ This method plots the uncertainty associated with the positions of the edges of
817
+ the device. The uncertainty is represented as a gradient, with areas of higher
818
+ uncertainty indicating a greater likelihood of the edge position from run to run
819
+ (due to inconsistencies in the fabrication process). This visualization can help
820
+ in identifying areas within the device geometry that may require design
821
+ adjustments to improve fabrication consistency.
822
+
823
+ Parameters
824
+ ----------
825
+ show_buffer : bool, optional
826
+ If True, the buffer zones around the device will also be visualized. By
827
+ default, it is set to True.
828
+ bounds : Optional[tuple[tuple[int, int], tuple[int, int]]], optional
829
+ Specifies the bounds for zooming into the device geometry, formatted as
830
+ ((min_x, min_y), (max_x, max_y)). If 'max_x' or 'max_y' is set to "end", it
831
+ will be replaced with the corresponding dimension size of the device array.
832
+ If None, the entire device geometry is visualized.
833
+ level : int, optional
834
+ The vertical layer to plot. If None, the device geometry is flattened.
835
+ Defaults to None.
836
+ ax : Optional[Axes], optional
837
+ An existing matplotlib Axes object to draw the uncertainty visualization on.
838
+ If None, a new figure and axes will be created. Defaults to None.
839
+ **kwargs
840
+ Additional matplotlib parameters for plot customization.
841
+
842
+ Returns
843
+ -------
844
+ Axes
845
+ The matplotlib Axes object containing the uncertainty visualization. This
846
+ can be used for further customization or saving the plot after the method
847
+ returns.
848
+ """
849
+ uncertainty_array = self.get_uncertainty()
850
+
851
+ if level is None:
852
+ uncertainty_array = geometry.flatten(uncertainty_array)[:, :, 0]
853
+ else:
854
+ uncertainty_array = uncertainty_array[:, :, level]
855
+
856
+ mappable, ax = self._plot_base(
857
+ plot_array=uncertainty_array,
858
+ show_buffer=show_buffer,
859
+ bounds=bounds,
860
+ ax=ax,
861
+ **kwargs,
862
+ )
863
+ cbar = plt.colorbar(mappable, ax=ax)
864
+ cbar.set_label("Uncertainty (a.u.)")
865
+ return ax
866
+
867
+ def plot_compare(
868
+ self,
869
+ ref_device: "Device",
870
+ show_buffer: bool = True,
871
+ bounds: Optional[tuple[tuple[int, int], tuple[int, int]]] = None,
872
+ level: int = None,
873
+ ax: Optional[Axes] = None,
874
+ **kwargs,
875
+ ) -> Axes:
876
+ """
877
+ Visualizes the comparison between the current device geometry and a reference
878
+ device geometry.
879
+
880
+ Positive values (dilation) and negative values (erosion) are visualized with a
881
+ color map to indicate areas where the current device has expanded or contracted
882
+ relative to the reference.
883
+
884
+ Parameters
885
+ ----------
886
+ ref_device : Device
887
+ The reference device to compare against.
888
+ show_buffer : bool, optional
889
+ If True, visualizes the buffer zones around the device. Defaults to True.
890
+ bounds : Optional[tuple[tuple[int, int], tuple[int, int]]], optional
891
+ Specifies the bounds for zooming into the device geometry, formatted as
892
+ ((min_x, min_y), (max_x, max_y)). If 'max_x' or 'max_y' is set to "end", it
893
+ will be replaced with the corresponding dimension size of the device array.
894
+ If None, the entire device geometry is visualized.
895
+ level : int, optional
896
+ The vertical layer to plot. If None, the device geometry is flattened.
897
+ Defaults to None.
898
+ ax : Optional[Axes], optional
899
+ An existing matplotlib Axes object to draw the comparison on. If None, a new
900
+ figure and axes will be created. Defaults to None.
901
+ **kwargs
902
+ Additional matplotlib parameters for plot customization.
903
+
904
+ Returns
905
+ -------
906
+ Axes
907
+ The matplotlib Axes object containing the comparison plot. This object can
908
+ be used for further plot customization or saving the plot after the method
909
+ returns.
910
+ """
911
+ plot_array = ref_device.device_array - self.device_array
912
+
913
+ if level is None:
914
+ plot_array = geometry.flatten(plot_array)[:, :, 0]
915
+ else:
916
+ plot_array = plot_array[:, :, level]
917
+
918
+ mappable, ax = self._plot_base(
919
+ plot_array=plot_array,
920
+ show_buffer=show_buffer,
921
+ bounds=bounds,
922
+ ax=ax,
923
+ cmap="jet",
924
+ **kwargs,
925
+ )
926
+ cbar = plt.colorbar(mappable, ax=ax)
927
+ cbar.set_label("Added (a.u.) Removed (a.u.)")
928
+ return ax
929
+
930
+ def _add_buffer_visualization(self, ax: Axes):
931
+ plot_array = self.device_array
932
+
933
+ buffer_thickness = self.buffer_spec.thickness
934
+ buffer_fill = (0, 1, 0, 0.2)
935
+ buffer_hatch = "/"
936
+
937
+ mid_rect = Rectangle(
938
+ (buffer_thickness, buffer_thickness),
939
+ plot_array.shape[1] - 2 * buffer_thickness,
940
+ plot_array.shape[0] - 2 * buffer_thickness,
941
+ facecolor="none",
942
+ edgecolor="black",
943
+ linewidth=1,
944
+ )
945
+ ax.add_patch(mid_rect)
946
+
947
+ top_rect = Rectangle(
948
+ (0, 0),
949
+ plot_array.shape[1],
950
+ buffer_thickness,
951
+ facecolor=buffer_fill,
952
+ hatch=buffer_hatch,
953
+ )
954
+ ax.add_patch(top_rect)
955
+
956
+ bottom_rect = Rectangle(
957
+ (0, plot_array.shape[0] - buffer_thickness),
958
+ plot_array.shape[1],
959
+ buffer_thickness,
960
+ facecolor=buffer_fill,
961
+ hatch=buffer_hatch,
962
+ )
963
+ ax.add_patch(bottom_rect)
964
+
965
+ left_rect = Rectangle(
966
+ (0, buffer_thickness),
967
+ buffer_thickness,
968
+ plot_array.shape[0] - 2 * buffer_thickness,
969
+ facecolor=buffer_fill,
970
+ hatch=buffer_hatch,
971
+ )
972
+ ax.add_patch(left_rect)
973
+
974
+ right_rect = Rectangle(
975
+ (
976
+ plot_array.shape[1] - buffer_thickness,
977
+ buffer_thickness,
978
+ ),
979
+ buffer_thickness,
980
+ plot_array.shape[0] - 2 * buffer_thickness,
981
+ facecolor=buffer_fill,
982
+ hatch=buffer_hatch,
983
+ )
984
+ ax.add_patch(right_rect)
985
+
986
+ def normalize(self) -> "Device":
987
+ """
988
+ Normalize the device geometry.
989
+
990
+ Returns
991
+ -------
992
+ Device
993
+ A new instance of the Device with the normalized geometry.
994
+ """
995
+ normalized_device_array = geometry.normalize(device_array=self.device_array)
996
+ return self.model_copy(update={"device_array": normalized_device_array})
997
+
998
+ def binarize(self, eta: float = 0.5, beta: float = np.inf) -> "Device":
999
+ """
1000
+ Binarize the device geometry based on a threshold and a scaling factor.
1001
+
1002
+ Parameters
1003
+ ----------
1004
+ eta : float, optional
1005
+ The threshold value for binarization. Defaults to 0.5.
1006
+ beta : float, optional
1007
+ The scaling factor for the binarization process. A higher value makes the
1008
+ transition sharper. Defaults to np.inf, which results in a hard threshold.
1009
+
1010
+ Returns
1011
+ -------
1012
+ Device
1013
+ A new instance of the Device with the binarized geometry.
1014
+ """
1015
+ binarized_device_array = geometry.binarize(
1016
+ device_array=self.device_array, eta=eta, beta=beta
1017
+ )
1018
+ return self.model_copy(update={"device_array": binarized_device_array})
1019
+
1020
+ def binarize_hard(self, eta: float = 0.5) -> "Device":
1021
+ """
1022
+ Apply a hard threshold to binarize the device geometry. The `binarize` function
1023
+ is generally preferred for most use cases, but it can create numerical artifacts
1024
+ for large beta values.
1025
+
1026
+ Parameters
1027
+ ----------
1028
+ eta : float, optional
1029
+ The threshold value for binarization. Defaults to 0.5.
1030
+
1031
+ Returns
1032
+ -------
1033
+ Device
1034
+ A new instance of the Device with the threshold-binarized geometry.
1035
+ """
1036
+ binarized_device_array = geometry.binarize_hard(
1037
+ device_array=self.device_array, eta=eta
1038
+ )
1039
+ return self.model_copy(update={"device_array": binarized_device_array})
1040
+
1041
+ def binarize_monte_carlo(
1042
+ self,
1043
+ threshold_noise_std: float = 2.0,
1044
+ threshold_blur_std: float = 9.0,
1045
+ ) -> "Device":
1046
+ """
1047
+ Binarize the device geometry using a Monte Carlo approach with Gaussian
1048
+ blurring.
1049
+
1050
+ This method applies a dynamic thresholding technique where the threshold value
1051
+ is determined by a base value perturbed by Gaussian-distributed random noise.
1052
+ The threshold is then spatially varied across the device array using Gaussian
1053
+ blurring, simulating a more realistic scenario where the threshold is not
1054
+ uniform across the device.
1055
+
1056
+ Parameters
1057
+ ----------
1058
+ threshold_noise_std : float, optional
1059
+ The standard deviation of the Gaussian distribution used to generate noise
1060
+ for the threshold values. This controls the amount of randomness in the
1061
+ threshold. Defaults to 2.0.
1062
+ threshold_blur_std : float, optional
1063
+ The standard deviation for the Gaussian kernel used in blurring the
1064
+ threshold map. This controls the spatial variation of the threshold across
1065
+ the array. Defaults to 9.0.
1066
+
1067
+ Returns
1068
+ -------
1069
+ Device
1070
+ A new instance of the Device with the binarized geometry.
1071
+ """
1072
+ binarized_device_array = geometry.binarize_monte_carlo(
1073
+ device_array=self.device_array,
1074
+ threshold_noise_std=threshold_noise_std,
1075
+ threshold_blur_std=threshold_blur_std,
1076
+ )
1077
+ return self.model_copy(update={"device_array": binarized_device_array})
1078
+
1079
+ def ternarize(self, eta1: float = 1 / 3, eta2: float = 2 / 3) -> "Device":
1080
+ """
1081
+ Ternarize the device geometry based on two thresholds. This function is useful
1082
+ for flattened devices with angled sidewalls (i.e., three segments).
1083
+
1084
+ Parameters
1085
+ ----------
1086
+ eta1 : float, optional
1087
+ The first threshold value for ternarization. Defaults to 1/3.
1088
+ eta2 : float, optional
1089
+ The second threshold value for ternarization. Defaults to 2/3.
1090
+
1091
+ Returns
1092
+ -------
1093
+ Device
1094
+ A new instance of the Device with the ternarized geometry.
1095
+ """
1096
+ ternarized_device_array = geometry.ternarize(
1097
+ device_array=self.flatten().device_array, eta1=eta1, eta2=eta2
1098
+ )
1099
+ return self.model_copy(update={"device_array": ternarized_device_array})
1100
+
1101
+ def trim(self) -> "Device":
1102
+ """
1103
+ Trim the device geometry by removing empty space around it.
1104
+
1105
+ Parameters
1106
+ ----------
1107
+ buffer_thickness : int, optional
1108
+ The thickness of the buffer to leave around the empty space. Defaults to 0,
1109
+ which means no buffer is added.
1110
+
1111
+ Returns
1112
+ -------
1113
+ Device
1114
+ A new instance of the Device with the trimmed geometry.
1115
+ """
1116
+ trimmed_device_array = geometry.trim(
1117
+ device_array=self.device_array,
1118
+ buffer_thickness=self.buffer_spec.thickness,
1119
+ )
1120
+ return self.model_copy(update={"device_array": trimmed_device_array})
1121
+
1122
+ def blur(self, sigma: float = 1.0) -> "Device":
1123
+ """
1124
+ Apply Gaussian blur to the device geometry and normalize the result.
1125
+
1126
+ Parameters
1127
+ ----------
1128
+ sigma : float, optional
1129
+ The standard deviation for the Gaussian kernel. This controls the amount of
1130
+ blurring. Defaults to 1.0.
1131
+
1132
+ Returns
1133
+ -------
1134
+ Device
1135
+ A new instance of the Device with the blurred and normalized geometry.
1136
+ """
1137
+ blurred_device_array = geometry.blur(
1138
+ device_array=self.device_array, sigma=sigma
1139
+ )
1140
+ return self.model_copy(update={"device_array": blurred_device_array})
1141
+
1142
+ def rotate(self, angle: float) -> "Device":
1143
+ """
1144
+ Rotate the device geometry by a given angle.
1145
+
1146
+ Parameters
1147
+ ----------
1148
+ angle : float
1149
+ The angle of rotation in degrees. Positive values mean counter-clockwise
1150
+ rotation.
1151
+
1152
+ Returns
1153
+ -------
1154
+ Device
1155
+ A new instance of the Device with the rotated geometry.
1156
+ """
1157
+ rotated_device_array = geometry.rotate(
1158
+ device_array=self.device_array, angle=angle
1159
+ )
1160
+ return self.model_copy(update={"device_array": rotated_device_array})
1161
+
1162
+ def erode(self, kernel_size: int = 3) -> "Device":
1163
+ """
1164
+ Erode the device geometry by removing small areas of overlap.
1165
+
1166
+ Parameters
1167
+ ----------
1168
+ kernel_size : int
1169
+ The size of the kernel used for erosion.
1170
+
1171
+ Returns
1172
+ -------
1173
+ Device
1174
+ A new instance of the Device with the eroded geometry.
1175
+ """
1176
+ eroded_device_array = geometry.erode(
1177
+ device_array=self.device_array, kernel_size=kernel_size
1178
+ )
1179
+ return self.model_copy(update={"device_array": eroded_device_array})
1180
+
1181
+ def dilate(self, kernel_size: int = 3) -> "Device":
1182
+ """
1183
+ Dilate the device geometry by expanding areas of overlap.
1184
+
1185
+ Parameters
1186
+ ----------
1187
+ kernel_size : int
1188
+ The size of the kernel used for dilation.
1189
+
1190
+ Returns
1191
+ -------
1192
+ Device
1193
+ A new instance of the Device with the dilated geometry.
1194
+ """
1195
+ dilated_device_array = geometry.dilate(
1196
+ device_array=self.device_array, kernel_size=kernel_size
1197
+ )
1198
+ return self.model_copy(update={"device_array": dilated_device_array})
1199
+
1200
+ def flatten(self) -> "Device":
1201
+ """
1202
+ Flatten the device geometry by summing the vertical layers and normalizing the
1203
+ result.
1204
+
1205
+ Parameters
1206
+ ----------
1207
+ device_array : np.ndarray
1208
+ The input array to be flattened.
1209
+
1210
+ Returns
1211
+ -------
1212
+ np.ndarray
1213
+ The flattened array with values scaled between 0 and 1.
1214
+ """
1215
+ flattened_device_array = geometry.flatten(device_array=self.device_array)
1216
+ return self.model_copy(update={"device_array": flattened_device_array})
1217
+
1218
+ def get_uncertainty(self) -> np.ndarray:
1219
+ """
1220
+ Calculate the uncertainty in the edge positions of the predicted device.
1221
+
1222
+ This method computes the uncertainty based on the deviation of the device's
1223
+ geometry values from the midpoint (0.5). The uncertainty is defined as the
1224
+ absolute difference from 0.5, scaled and inverted to provide a measure where
1225
+ higher values indicate greater uncertainty.
1226
+
1227
+ Returns
1228
+ -------
1229
+ np.ndarray
1230
+ An array representing the uncertainty in the edge positions of the device,
1231
+ with higher values indicating greater uncertainty.
1232
+ """
1233
+ return 1 - 2 * np.abs(0.5 - self.device_array)