photonforge 1.3.0__cp310-cp310-win_amd64.whl → 1.3.2__cp310-cp310-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. photonforge/__init__.py +17 -12
  2. photonforge/_backend/default_project.py +398 -22
  3. photonforge/circuit_base.py +5 -40
  4. photonforge/extension.cp310-win_amd64.pyd +0 -0
  5. photonforge/live_viewer.py +2 -2
  6. photonforge/{analytic_models.py → models/analytic.py} +47 -23
  7. photonforge/models/circuit.py +684 -0
  8. photonforge/{data_model.py → models/data.py} +4 -4
  9. photonforge/{tidy3d_model.py → models/tidy3d.py} +772 -10
  10. photonforge/parametric.py +60 -28
  11. photonforge/plotting.py +1 -1
  12. photonforge/pretty.py +1 -1
  13. photonforge/thumbnails/electrical_absolute.svg +8 -0
  14. photonforge/thumbnails/electrical_adder.svg +9 -0
  15. photonforge/thumbnails/electrical_amplifier.svg +5 -0
  16. photonforge/thumbnails/electrical_differential.svg +6 -0
  17. photonforge/thumbnails/electrical_integral.svg +8 -0
  18. photonforge/thumbnails/electrical_multiplier.svg +9 -0
  19. photonforge/thumbnails/filter.svg +8 -0
  20. photonforge/thumbnails/optical_amplifier.svg +5 -0
  21. photonforge/thumbnails.py +10 -38
  22. photonforge/time_steppers/amplifier.py +353 -0
  23. photonforge/{analytic_time_steppers.py → time_steppers/analytic.py} +191 -2
  24. photonforge/{circuit_time_stepper.py → time_steppers/circuit.py} +6 -5
  25. photonforge/time_steppers/filter.py +400 -0
  26. photonforge/time_steppers/math.py +331 -0
  27. photonforge/{modulator_time_steppers.py → time_steppers/modulator.py} +9 -20
  28. photonforge/{s_matrix_time_stepper.py → time_steppers/s_matrix.py} +3 -3
  29. photonforge/{sink_time_steppers.py → time_steppers/sink.py} +6 -8
  30. photonforge/{source_time_steppers.py → time_steppers/source.py} +20 -18
  31. photonforge/typing.py +5 -0
  32. photonforge/utils.py +89 -15
  33. {photonforge-1.3.0.dist-info → photonforge-1.3.2.dist-info}/METADATA +2 -2
  34. {photonforge-1.3.0.dist-info → photonforge-1.3.2.dist-info}/RECORD +37 -27
  35. photonforge/circuit_model.py +0 -335
  36. photonforge/eme_model.py +0 -816
  37. {photonforge-1.3.0.dist-info → photonforge-1.3.2.dist-info}/WHEEL +0 -0
  38. {photonforge-1.3.0.dist-info → photonforge-1.3.2.dist-info}/entry_points.txt +0 -0
  39. {photonforge-1.3.0.dist-info → photonforge-1.3.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,684 @@
1
+ import struct
2
+ import threading
3
+ import time
4
+ from collections.abc import Sequence
5
+ from typing import Any, Literal
6
+
7
+ import numpy
8
+ from scipy.optimize import minimize
9
+
10
+ from .. import typing as pft
11
+ from ..cache import _mode_overlap_cache, cache_s_matrix
12
+ from ..circuit_base import _process_component_netlist
13
+ from ..extension import (
14
+ Component,
15
+ Model,
16
+ Path,
17
+ Port,
18
+ SMatrix,
19
+ _connect_s_matrices,
20
+ config,
21
+ frequency_classification,
22
+ register_model_class,
23
+ )
24
+ from ..utils import _gather_status
25
+ from .analytic import WaveguideModel
26
+ from .tidy3d import Tidy3DModel, _align_and_overlap
27
+
28
+
29
+ class _CircuitModelRunner:
30
+ def __init__(
31
+ self,
32
+ runners: dict[Any, Any],
33
+ frequencies: Sequence[float],
34
+ component_name: str,
35
+ ports: dict[str, Port],
36
+ port_connections: dict[str, tuple[int, str, int]],
37
+ connections: Sequence[tuple[tuple[int, str, int], tuple[int, str, int]]],
38
+ instance_port_data: Sequence[tuple[Any, Any]],
39
+ cost_estimation: bool,
40
+ ) -> None:
41
+ self.runners = runners
42
+ self.frequencies = frequencies
43
+ self.component_name = component_name
44
+ self.ports = ports
45
+ self.port_connections = port_connections
46
+ self.connections = connections
47
+ self.instance_port_data = instance_port_data
48
+ self.cost_estimation = cost_estimation
49
+
50
+ self.lock = threading.Lock()
51
+ self._s_matrix = None
52
+ self._status = {"progress": 0, "message": "running", "tasks": {}}
53
+
54
+ self.thread = threading.Thread(daemon=True, target=self._run_and_monitor_task)
55
+ self.thread.start()
56
+
57
+ def _run_and_monitor_task(self):
58
+ task_status = _gather_status(*self.runners.values())
59
+ w_tasks = 3 * len(task_status["tasks"])
60
+ n_ports = len(self.instance_port_data)
61
+ n_connections = len(self.connections)
62
+ denominator = max(1, w_tasks + n_ports + n_connections)
63
+
64
+ with self.lock:
65
+ self._status = dict(task_status)
66
+ self._status["progress"] *= w_tasks / denominator
67
+
68
+ while task_status["message"] == "running":
69
+ time.sleep(0.3)
70
+ task_status = _gather_status(*self.runners.values())
71
+ with self.lock:
72
+ self._status = dict(task_status)
73
+ self._status["progress"] *= w_tasks / denominator
74
+
75
+ if task_status["message"] == "error":
76
+ with self.lock:
77
+ self._status = task_status
78
+ return
79
+
80
+ with self.lock:
81
+ self._status = task_status
82
+ if self.cost_estimation:
83
+ return
84
+ self._status["message"] = "running"
85
+ self._status["progress"] *= w_tasks / denominator
86
+
87
+ s_dict = {}
88
+ for index, (instance_ports, instance_keys) in enumerate(self.instance_port_data):
89
+ # Check if reference is needed
90
+ if instance_ports is None:
91
+ continue
92
+
93
+ runner = self.runners[index]
94
+ s_matrix = runner if isinstance(runner, SMatrix) else runner.s_matrix
95
+ if s_matrix is None:
96
+ with self.lock:
97
+ self._status["message"] = "error"
98
+ return
99
+
100
+ # Fix port phases if a rotation is applied
101
+ mode_factor = {
102
+ f"{port_name}@{mode}": 1.0
103
+ for port_name, port in instance_ports
104
+ for mode in range(port.num_modes)
105
+ }
106
+
107
+ if instance_keys is not None:
108
+ for port_name, port in instance_ports:
109
+ key = instance_keys.get(port_name)
110
+ if key is None:
111
+ continue
112
+
113
+ # Port mode
114
+ overlap = _mode_overlap_cache[key]
115
+ if overlap is None:
116
+ overlap = _align_and_overlap(
117
+ self.runners[(index, port_name, 0)].data,
118
+ self.runners[(index, port_name, 1)].data,
119
+ )[0]
120
+ _mode_overlap_cache[key] = overlap
121
+
122
+ for mode in range(port.num_modes):
123
+ mode_factor[f"{port_name}@{mode}"] = overlap[mode]
124
+
125
+ for (i, j), s_ji in s_matrix.elements.items():
126
+ s_dict[(index, i), (index, j)] = s_ji * mode_factor[i] / mode_factor[j]
127
+
128
+ with self.lock:
129
+ self._status["progress"] = 100 * (w_tasks + index + 1) / denominator
130
+
131
+ s_dict = _connect_s_matrices(s_dict, self.connections, len(self.instance_port_data))
132
+
133
+ # Build S matrix with desired ports
134
+ ports = {
135
+ (index, f"{ref_name}@{n}"): f"{port_name}@{n}"
136
+ for (index, ref_name, modes), port_name in self.port_connections.items()
137
+ for n in range(modes)
138
+ }
139
+
140
+ elements = {
141
+ (ports[i], ports[j]): s_ji
142
+ for (i, j), s_ji in s_dict.items()
143
+ if i in ports and j in ports
144
+ }
145
+
146
+ with self.lock:
147
+ self._s_matrix = SMatrix(self.frequencies, elements, self.ports)
148
+ self._status["progress"] = 100
149
+ self._status["message"] = "success"
150
+
151
+ @property
152
+ def status(self) -> dict[str, Any]:
153
+ with self.lock:
154
+ return self._status
155
+
156
+ @property
157
+ def s_matrix(self) -> SMatrix:
158
+ with self.lock:
159
+ return self._s_matrix
160
+
161
+
162
+ class CircuitModel(Model):
163
+ """Model based on circuit-level S-parameter calculation.
164
+
165
+ The component is expected to be composed of interconnected references.
166
+ Scattering parameters are computed based on the S matrices from all
167
+ references and their interconnections.
168
+
169
+ The S matrix of each reference is calculated based on the active model
170
+ of the reference's component. Each calculation is preceded by an update
171
+ to the componoent's technology, the component itself, and its active
172
+ model by calling :attr:`Reference.update`. They are reset to their
173
+ original state after the :func:`CircuitModel.start` function is called.
174
+ Keyword arguents in :attr:`Reference.s_matrix_kwargs` will be passed on
175
+ to :func:`CircuitModel.start`.
176
+
177
+ If a reference includes repetitions, it is flattened so that each
178
+ instance is called separately.
179
+
180
+ Connections between incompatible ports (butt couplings) are handled
181
+ automatically. For electrical ports, if either one has impedance
182
+ information, the coupling matrix is approximated by reflection and
183
+ transmission coefficients derived from both port impedances. For optical
184
+ ports and electrical ports without impedance information, an
185
+ :class:`EMEModel` is used to calculate the butt coupling coefficients.
186
+
187
+ Args:
188
+ mesh_refinement: Minimal number of mesh elements per wavelength used
189
+ for mode solving.
190
+ verbose: Flag setting the verbosity of mode solver runs.
191
+
192
+ See also:
193
+ - :func:`Component.get_netlist`
194
+ - :attr:`PortSpec.impedance`
195
+ - `Circuit Model guide <../guides/Circuit_Model.ipynb>`__
196
+ """
197
+
198
+ def __init__(
199
+ self,
200
+ mesh_refinement: pft.PositiveFloat | None = None,
201
+ verbose: bool = True,
202
+ ):
203
+ super().__init__(mesh_refinement=mesh_refinement, verbose=verbose)
204
+
205
+ @property
206
+ def mesh_refinement(self):
207
+ return self.parametric_kwargs["mesh_refinement"]
208
+
209
+ @mesh_refinement.setter
210
+ def mesh_refinement(self, value):
211
+ self.parametric_kwargs["mesh_refinement"] = value
212
+
213
+ @property
214
+ def verbose(self):
215
+ return self.parametric_kwargs["verbose"]
216
+
217
+ @verbose.setter
218
+ def verbose(self, value):
219
+ self.parametric_kwargs["verbose"] = value
220
+
221
+ @cache_s_matrix
222
+ def start(
223
+ self,
224
+ component: Component,
225
+ frequencies: Sequence[float],
226
+ updates: dict[Sequence[str | int | None], dict[str, dict[str, Any]]] = {},
227
+ chain_technology_updates: bool = True,
228
+ verbose: bool | None = None,
229
+ cost_estimation: bool = False,
230
+ **kwargs: Any,
231
+ ) -> _CircuitModelRunner:
232
+ """Start computing the S matrix response from a component.
233
+
234
+ Args:
235
+ component: Component from which to compute the S matrix.
236
+ frequencies: Sequence of frequencies at which to perform the
237
+ computation.
238
+ updates: Dictionary of parameter updates to be applied to
239
+ components, technologies, and models for references within the
240
+ main component. See below for further information.
241
+ chain_technology_updates: if set, a technology update will trigger
242
+ an update for all components using that technology.
243
+ verbose: If set, overrides the model's ``verbose`` attribute and
244
+ is passed to reference models.
245
+ cost_estimation: If set, Tidy3D simulations are uploaded, but not
246
+ executed. S matrix will *not* be computed.
247
+ **kwargs: Keyword arguments passed to reference models.
248
+
249
+ Returns:
250
+ Result object with attributes ``status`` and ``s_matrix``.
251
+
252
+ The ``'updates'`` dictionary contains keyword arguments for the
253
+ :func:`Reference.update` function for the references in the component
254
+ dependency tree, such that, when the S parameter of a specific reference
255
+ are computed, that reference can be updated without affecting others
256
+ using the same component.
257
+
258
+ Each key in the dictionary is used as a reference specification. It must
259
+ be a tuple with any number of the following:
260
+
261
+ - ``name: str | re.Pattern``: selects any reference whose component name
262
+ matches the given regex.
263
+
264
+ - ``i: int``, directly following ``name``: limits the selection to
265
+ ``reference[i]`` from the list of references matching the name. A
266
+ negative value will match all list items. Note that each repetiton in
267
+ a reference array counts as a single element in the list.
268
+
269
+ - ``None``: matches any reference at any depth.
270
+
271
+ Examples:
272
+ >>> updates = {
273
+ ... # Apply component updates to the first "ARM" reference in
274
+ ... # the main component
275
+ ... ("ARM", 0): {"component_updates": {"radius": 10}}
276
+ ... # Apply model updates to the second "BEND" reference under
277
+ ... # any "SUB" references in the main component
278
+ ... ("SUB", "BEND", 1): {"model_updates": {"verbose": False}}
279
+ ... # Apply technology updates to references with component name
280
+ ... # starting with "COMP_" prefix, at any subcomponent depth
281
+ ... (None, "COMP.*"): {"technology_updates": {"thickness": 0.3}}
282
+ ... }
283
+ >>> s_matrix = component.s_matrix(
284
+ ... frequencies, model_kwargs={"updates": updates}
285
+ ... )
286
+
287
+ See also:
288
+ - `Circuit Model guide <../guides/Circuit_Model.ipynb>`__
289
+ - `Cascaded Rings Filter example
290
+ <../examples/Cascaded_Rings_Filter.ipynb>`__
291
+ """
292
+ if verbose is None:
293
+ verbose = self.verbose
294
+ s_matrix_kwargs = {}
295
+ else:
296
+ s_matrix_kwargs = {"verbose": verbose}
297
+ if cost_estimation:
298
+ s_matrix_kwargs["cost_estimation"] = cost_estimation
299
+
300
+ frequencies = numpy.array(frequencies, dtype=float, ndmin=1)
301
+ runners, _, component_ports, port_connections, connections, instance_port_data, _ = (
302
+ _process_component_netlist(
303
+ component,
304
+ frequencies,
305
+ self.mesh_refinement,
306
+ {},
307
+ updates,
308
+ chain_technology_updates,
309
+ verbose,
310
+ cost_estimation,
311
+ kwargs,
312
+ s_matrix_kwargs,
313
+ None,
314
+ )
315
+ )
316
+
317
+ return _CircuitModelRunner(
318
+ runners,
319
+ frequencies,
320
+ component.name,
321
+ component_ports,
322
+ port_connections,
323
+ connections,
324
+ instance_port_data,
325
+ cost_estimation,
326
+ )
327
+
328
+ # Deprecated: kept for backwards compatibility with old phf files
329
+ @classmethod
330
+ def from_bytes(cls, byte_repr: bytes) -> "CircuitModel":
331
+ """De-serialize this model."""
332
+ (version, verbose, mesh_refinement) = struct.unpack("<B?d", byte_repr)
333
+ if version != 0:
334
+ raise RuntimeError("Unsuported CircuitModel version.")
335
+
336
+ if mesh_refinement <= 0:
337
+ mesh_refinement = None
338
+ return cls(mesh_refinement, verbose)
339
+
340
+
341
+ def _subpath_length(path, u0, u1, npts):
342
+ vecs = numpy.diff([path.at(u, output="position") for u in numpy.linspace(u0, u1, npts)], axis=0)
343
+ return ((vecs[:, 0] ** 2 + vecs[:, 1] ** 2) ** 0.5).sum()
344
+
345
+
346
+ _coupler_model = Tidy3DModel()
347
+ _arms_model = Tidy3DModel()
348
+ _circuit_model = CircuitModel()
349
+
350
+
351
+ class DirectionalCouplerCircuitModel(Model):
352
+ """Model for directional couplers based on circuit decomposition.
353
+
354
+ The coupler is subdivided into 5 parts: the 4 arms and the coupling
355
+ region. Each is simulated separately, and the final S parameters are
356
+ computed by a :class:`CircuitModel`.
357
+
358
+ The component geometry is expected to be composed of at least 2 paths
359
+ connecting 2 ports each. The paths must start far apart, come close
360
+ together in the coupling region, and separate again. The distance
361
+ between paths is used to determine the extents of the arms and coupling
362
+ region.
363
+
364
+ Args:
365
+ coupling_region_model: Model used to compute the S parameters in the
366
+ coupling region.
367
+ arms_model: Model used for the arms. A dictionary mapping port names
368
+ to models can be used to set a specific models for each arm.
369
+ circuit_model: Model used to merge all component parts.
370
+ """
371
+
372
+ def __init__(
373
+ self,
374
+ *,
375
+ coupling_region_model: Model = _coupler_model,
376
+ arms_model: Model | dict[str, Model] = _arms_model,
377
+ circuit_model: Model = _circuit_model,
378
+ ):
379
+ super().__init__(
380
+ coupling_region_model=coupling_region_model,
381
+ arms_model=arms_model,
382
+ circuit_model=circuit_model,
383
+ )
384
+
385
+ def get_circuit(
386
+ self, component: Component, classification: Literal["optical", "electrical"] = "optical"
387
+ ) -> Component:
388
+ """Build the circuit equivalent for a component.
389
+
390
+ Args:
391
+ component: Component to subdivide into arms and coupling region.
392
+ classification: Frequency classification to use.
393
+
394
+ Returns:
395
+ Subdivided component.
396
+ """
397
+ ports = sorted(component.select_ports(classification).items(), key=lambda x: x[1].center[0])
398
+ if len(ports) != 4:
399
+ raise RuntimeError(
400
+ f"Component {component.name!r} must have 4 {classification} ports to use "
401
+ f"'DirectionalCouplerCircuitModel'."
402
+ )
403
+ if not all(isinstance(port, Port) for _, port in ports):
404
+ raise RuntimeError(f"All ports in {component.name!r} must be 'Port' instances.")
405
+
406
+ structures = component.structures
407
+
408
+ # Expect the following port configuration:
409
+ # P1 _ _ P3
410
+ # \_/
411
+ # P0 ----- P2
412
+ (n0, p0), (n1, p1), (n2, p2), (n3, p3) = ports
413
+ if p0.center[1] > p1.center[1]:
414
+ p0, p1 = p1, p0
415
+ n0, n1 = n1, n0
416
+
417
+ path_profiles = p0.spec.path_profiles_list()
418
+ if len(path_profiles) == 0:
419
+ raise RuntimeError(
420
+ "Path profiles must not be empty to use an 'DirectionalCouplerCircuitModel'."
421
+ )
422
+ layer0 = path_profiles[0][2]
423
+
424
+ # Find path0 that goes from p0 to p2
425
+ path0 = None
426
+ for s in structures[layer0]:
427
+ if not isinstance(s, Path):
428
+ continue
429
+ start = s.at(0, output="position")
430
+ end = s.at(s.size, output="position")
431
+ if numpy.allclose(p0.center, start):
432
+ if numpy.allclose(p2.center, end):
433
+ path0 = s
434
+ inverted0 = False
435
+ break
436
+ elif numpy.allclose(p1.center, end):
437
+ p1, p2 = p2, p1
438
+ n1, n2 = n2, n1
439
+ path0 = s
440
+ inverted0 = False
441
+ break
442
+ elif numpy.allclose(p3.center, end):
443
+ p3, p2 = p2, p3
444
+ n3, n2 = n2, n3
445
+ path0 = s
446
+ inverted0 = False
447
+ break
448
+ elif numpy.allclose(p0.center, end):
449
+ if numpy.allclose(p2.center, start):
450
+ path0 = s
451
+ inverted0 = True
452
+ break
453
+ elif numpy.allclose(p1.center, start):
454
+ p1, p2 = p2, p1
455
+ n1, n2 = n2, n1
456
+ path0 = s
457
+ inverted0 = True
458
+ break
459
+ elif numpy.allclose(p3.center, start):
460
+ p3, p2 = p2, p3
461
+ n3, n2 = n2, n3
462
+ path0 = s
463
+ inverted0 = True
464
+ break
465
+ if path0 is None:
466
+ raise RuntimeError(
467
+ f"Unable to find a path in layer {layer0} conneting port {n0!r} to any other port "
468
+ f"in component {component.name!r}."
469
+ )
470
+
471
+ v_axis = p2.center - p0.center
472
+ axis = 0 if abs(v_axis[0]) >= abs(v_axis[1]) else 1
473
+ if p1.center[axis] > p3.center[axis]:
474
+ p3, p1 = p1, p3
475
+ n3, n1 = n1, n3
476
+
477
+ # Find path1 from p3 to p1
478
+ path_profiles = p3.spec.path_profiles_list()
479
+ if len(path_profiles) == 0:
480
+ raise RuntimeError(
481
+ "Path profiles must not be empty to use an 'DirectionalCouplerCircuitModel'."
482
+ )
483
+ layer1 = path_profiles[0][2]
484
+ path1 = None
485
+ for s in structures[layer1]:
486
+ if not isinstance(s, Path):
487
+ continue
488
+ start = s.at(0, output="position")
489
+ end = s.at(s.size, output="position")
490
+ if numpy.allclose(p3.center, start) and numpy.allclose(p1.center, end):
491
+ path1 = s
492
+ inverted1 = False
493
+ break
494
+ elif numpy.allclose(p1.center, start) and numpy.allclose(p3.center, end):
495
+ path1 = s
496
+ inverted1 = True
497
+ break
498
+ if path1 is None:
499
+ raise RuntimeError(
500
+ f"Unable to find a path in layer {layer1} conneting ports {n1!r} and {n3!r} in "
501
+ f"component {component.name!r}."
502
+ )
503
+
504
+ offset0 = p0.spec.width * 2**-0.5
505
+ offset1 = p3.spec.width * 2**-0.5
506
+
507
+ if inverted0:
508
+ offset0 = -offset0
509
+ if inverted1:
510
+ offset1 = -offset1
511
+
512
+ v_axis = 0.5 * (p2.center - p0.center + p3.center - p1.center)
513
+ v_norm = 0.5 * (p1.center - p0.center + p3.center - p2.center)
514
+ if v_axis[0] * v_norm[1] - v_axis[1] * v_norm[0] < 0:
515
+ # if the normal is to the right of the propagation axis, invert the offsets
516
+ offset0 = -offset0
517
+ offset1 = -offset1
518
+
519
+ size0 = path0.size
520
+ npts0 = path0.spine().shape[0]
521
+
522
+ size1 = path1.size
523
+ npts1 = path1.spine().shape[0]
524
+
525
+ # Find crossings between path0 and path1 with respective offsets
526
+ def distance_sq(u):
527
+ x0, _, _, g0 = path0.at(u[0] * size0)
528
+ x1, _, _, g1 = path1.at(u[1] * size1)
529
+ n0 = numpy.array([-g0[1], g0[0]]) * (g0[0] ** 2 + g0[1] ** 2) ** -0.5
530
+ n1 = numpy.array([-g1[1], g1[0]]) * (g1[0] ** 2 + g1[1] ** 2) ** -0.5
531
+ x0 += n0 * offset0
532
+ x1 += n1 * offset1
533
+ v = x1 - x0
534
+ return v[0] ** 2 + v[1] ** 2
535
+
536
+ minimize_kwargs = {"method": "Nelder-Mead", "options": {"adaptive": False, "maxiter": 500}}
537
+
538
+ bounds = [(0.5, 1.0) if inverted0 else (0.0, 0.5), (0.0, 0.5) if inverted1 else (0.5, 1.0)]
539
+ x0 = [bounds[0][0] + 0.25, bounds[1][0] + 0.25]
540
+ res = minimize(distance_sq, x0=x0, bounds=bounds, **minimize_kwargs)
541
+ if not res.success and res.fun > (2 * config.tolerance) ** 2:
542
+ raise RuntimeError(
543
+ f"Unable to find start of coupling region from path intersections in component "
544
+ f"{component.name!r}."
545
+ )
546
+
547
+ u = res.x[0] * size0
548
+ center0, _, _, grad = path0.at(u)
549
+ angle0 = numpy.arctan2(grad[1], grad[0]) / numpy.pi * 180.0
550
+ length0 = _subpath_length(path0, u, size0 if inverted0 else 0.0, npts0)
551
+
552
+ u = res.x[1] * size1
553
+ center1, _, _, grad = path1.at(u)
554
+ angle1 = numpy.arctan2(grad[1], grad[0]) / numpy.pi * 180.0
555
+ length1 = _subpath_length(path1, u, 0.0 if inverted1 else size1, npts1)
556
+
557
+ bounds = [(0.0, 0.5) if inverted0 else (0.5, 1.0), (0.5, 1.0) if inverted1 else (0.0, 0.5)]
558
+ # x0 = [1.0 - res.x[0], 1.0 - res.x[1]]
559
+ x0 = [bounds[0][0] + 0.25, bounds[1][0] + 0.25]
560
+ res = minimize(distance_sq, x0=x0, bounds=bounds, **minimize_kwargs)
561
+ if not res.success and res.fun > (2 * config.tolerance) ** 2:
562
+ raise RuntimeError(
563
+ f"Unable to find start of coupling region from path intersections in component "
564
+ f"{component.name!r}."
565
+ )
566
+
567
+ u = res.x[0] * size0
568
+ center2, _, _, grad = path0.at(u)
569
+ angle2 = numpy.arctan2(grad[1], grad[0]) / numpy.pi * 180.0
570
+ length2 = _subpath_length(path0, u, 0.0 if inverted0 else size0, npts0)
571
+
572
+ u = res.x[1] * size1
573
+ center3, _, _, grad = path1.at(u)
574
+ angle3 = numpy.arctan2(grad[1], grad[0]) / numpy.pi * 180.0
575
+ length3 = _subpath_length(path1, u, size1 if inverted1 else 0.0, npts1)
576
+
577
+ if inverted0:
578
+ angle0 += 180
579
+ else:
580
+ angle2 += 180
581
+
582
+ if inverted1:
583
+ angle3 += 180
584
+ else:
585
+ angle1 += 180
586
+
587
+ p = self.parametric_kwargs
588
+ arms_model = p["arms_model"]
589
+ if isinstance(arms_model, Model):
590
+ arms_model = {n0: arms_model, n1: arms_model, n2: arms_model, n3: arms_model}
591
+
592
+ coupler = Component(f"Coupling region - {component.name}", technology=component.technology)
593
+ offset = max(abs(offset0), abs(offset1))
594
+ arms = []
595
+ for i, (name, port, center, angle, length) in enumerate(
596
+ [
597
+ (n0, p0, center0, angle0, length0),
598
+ (n1, p1, center1, angle1, length1),
599
+ (n2, p2, center2, angle2, length2),
600
+ (n3, p3, center3, angle3, length3),
601
+ ]
602
+ ):
603
+ coupler.add_port(Port(center, angle, port.spec, extended=False), name)
604
+
605
+ c = Component(f"Arm {i} - {component.name}", technology=component.technology)
606
+ c.add_port(port, name)
607
+ c.add_port(Port(center, angle - 180, port.spec.inverted(), extended=False), f"X{i}")
608
+
609
+ # Get bounds from ports only; we don't want the original geometry bounds
610
+ a, b = c.bounds()
611
+ bounds = [a - offset, b + offset]
612
+
613
+ arm_model = arms_model.get(name, _arms_model)
614
+ if (
615
+ isinstance(arm_model, WaveguideModel)
616
+ and arm_model.parametric_kwargs["length"] is None
617
+ ):
618
+ arm_model = arm_model.__copy__().update(length=length)
619
+ elif isinstance(arm_model, Tidy3DModel):
620
+ b = arm_model.parametric_kwargs["bounds"]
621
+ if all(b[i][j] is None for i in (0, 1) for j in (0, 1)):
622
+ arm_model = arm_model.__copy__().update(
623
+ bounds=[(*bounds[0], b[0][2]), (*bounds[1], b[1][2])]
624
+ )
625
+ c.add_model(arm_model)
626
+
627
+ c.add(component)
628
+ arms.append(c)
629
+
630
+ # Get bounds from ports only; we don't want the original geometry bounds
631
+ a, b = coupler.bounds()
632
+ bounds = [a - offset, b + offset]
633
+
634
+ # If the new ports are too close to the original ones, we must make sure to extend them for
635
+ # a correct simulation
636
+ port_extension = -component.size().max()
637
+ coupler.add(component)
638
+ for port in (p0, p1, p2, p3):
639
+ endpoint = port_extension * numpy.exp(1j * port.input_direction / 180.0 * numpy.pi)
640
+ for layer, path in port.spec.get_paths(port.center):
641
+ coupler.add(layer, path.segment(endpoint, relative=True))
642
+
643
+ coupler_model = p["coupling_region_model"]
644
+ if isinstance(coupler_model, Tidy3DModel):
645
+ b = coupler_model.parametric_kwargs["bounds"]
646
+ if all(b[i][j] is None for i in (0, 1) for j in (0, 1)):
647
+ coupler_model = coupler_model.__copy__().update(
648
+ bounds=[(*bounds[0], b[0][2]), (*bounds[1], b[1][2])]
649
+ )
650
+ coupler.add_model(coupler_model)
651
+
652
+ circuit = Component(f"Circuit - {component.name}", technology=component.technology)
653
+ circuit.add(*arms, coupler)
654
+ circuit.add_virtual_connection_by_instance(0, "X0", 4, n0)
655
+ circuit.add_virtual_connection_by_instance(1, "X1", 4, n1)
656
+ circuit.add_virtual_connection_by_instance(2, "X2", 4, n2)
657
+ circuit.add_virtual_connection_by_instance(3, "X3", 4, n3)
658
+ circuit.add_port(p0, n0)
659
+ circuit.add_port(p1, n1)
660
+ circuit.add_port(p2, n2)
661
+ circuit.add_port(p3, n3)
662
+ circuit.add_model(p["circuit_model"])
663
+
664
+ return circuit
665
+
666
+ @cache_s_matrix
667
+ def start(self, component: Component, frequencies: Sequence[float], **kwargs: Any):
668
+ """Start computing the S matrix response from a component.
669
+
670
+ Args:
671
+ component: Component from which to compute the S matrix.
672
+ frequencies: Sequence of frequencies at which to perform the
673
+ computation.
674
+ **kwargs: Keyword arguments passed to sub-models.
675
+
676
+ Returns:
677
+ Result object with attributes ``status`` and ``s_matrix``.
678
+ """
679
+ circuit = self.get_circuit(component, frequency_classification(frequencies))
680
+ return circuit.active_model.start(circuit, frequencies, **kwargs)
681
+
682
+
683
+ register_model_class(CircuitModel)
684
+ register_model_class(DirectionalCouplerCircuitModel)
@@ -6,10 +6,9 @@ from typing import Any, Literal
6
6
 
7
7
  import numpy
8
8
 
9
- from . import typing as pft
10
- from .analytic_models import _add_bb_text, _bb_layer
11
- from .cache import cache_s_matrix
12
- from .extension import (
9
+ from .. import typing as pft
10
+ from ..cache import cache_s_matrix
11
+ from ..extension import (
13
12
  Component,
14
13
  Interpolator,
15
14
  Model,
@@ -25,6 +24,7 @@ from .extension import (
25
24
  pole_residue_fit,
26
25
  register_model_class,
27
26
  )
27
+ from .analytic import _add_bb_text, _bb_layer
28
28
 
29
29
  InterpolationMethod = Literal["linear", "barycentric", "cubicspline", "pchip", "akima", "makima"]
30
30
  InterpolationCoords = Literal["real_imag", "mag_phase"]