|
7 | 7 | import numpy as np
|
8 | 8 | import pydantic.v1 as pd
|
9 | 9 |
|
| 10 | +from tidy3d import Box, ClipOperation, GeometryGroup, GridSpec, PolySlab, Structure |
10 | 11 | from tidy3d.components.base import cached_property
|
11 | 12 | from tidy3d.components.boundary import BroadbandModeABCSpec
|
12 | 13 | from tidy3d.components.geometry.utils_2d import snap_coordinate_to_grid
|
@@ -231,7 +232,9 @@ def sim_dict(self) -> SimulationMap:
|
231 | 232 | # Now, create simulations with wave port sources and mode solver monitors for computing port modes
|
232 | 233 | for network_index in self.matrix_indices_run_sim:
|
233 | 234 | task_name, sim_with_src = self._add_source_to_sim(network_index)
|
234 |
| - sim_dict[task_name] = sim_with_src |
| 235 | + |
| 236 | + # extrude structures if necessary and update simulation |
| 237 | + sim_dict[task_name] = self._extrude_port_structures(sim_with_src) |
235 | 238 |
|
236 | 239 | # Check final simulations for grid size at ports
|
237 | 240 | for _, sim in sim_dict.items():
|
@@ -443,5 +446,239 @@ def get_radiation_monitor_by_name(self, monitor_name: str) -> DirectivityMonitor
|
443 | 446 | return monitor
|
444 | 447 | raise Tidy3dKeyError(f"No radiation monitor named '{monitor_name}'.")
|
445 | 448 |
|
| 449 | + def get_antenna_metrics_data( |
| 450 | + self, |
| 451 | + port_amplitudes: Optional[dict[str, complex]] = None, |
| 452 | + monitor_name: Optional[str] = None, |
| 453 | + ) -> AntennaMetricsData: |
| 454 | + """Calculate antenna parameters using superposition of fields from multiple port excitations. |
| 455 | +
|
| 456 | + The method computes the radiated far fields and port excitation power wave amplitudes |
| 457 | + for a superposition of port excitations, which can be used to analyze antenna radiation |
| 458 | + characteristics. |
| 459 | +
|
| 460 | + Parameters |
| 461 | + ---------- |
| 462 | + port_amplitudes : dict[str, complex] = None |
| 463 | + Dictionary mapping port names to their desired excitation amplitudes, ``a``. For each port, |
| 464 | + :math:`\\frac{1}{2}|a|^2` represents the incident power from that port into the system. |
| 465 | + If ``None``, uses only the first port without any scaling of the raw simulation data. |
| 466 | + When ``None`` is passed as a port amplitude, the raw simulation data is used for that port. |
| 467 | + Note that in this method ``a`` represents the incident wave amplitude |
| 468 | + using the power wave definition in [2]. |
| 469 | + monitor_name : str = None |
| 470 | + Name of the :class:`.DirectivityMonitor` to use for calculating far fields. |
| 471 | + If None, uses the first monitor in `radiation_monitors`. |
| 472 | +
|
| 473 | + Returns |
| 474 | + ------- |
| 475 | + :class:`.AntennaMetricsData` |
| 476 | + Container with antenna parameters including directivity, gain, and radiation efficiency, |
| 477 | + computed from the superposition of fields from all excited ports. |
| 478 | + """ |
| 479 | + # Use the first port as default if none specified |
| 480 | + if port_amplitudes is None: |
| 481 | + port_amplitudes = {self.ports[0].name: None} |
| 482 | + |
| 483 | + # Check port names, and create map from port to amplitude |
| 484 | + port_dict = {} |
| 485 | + for key in port_amplitudes.keys(): |
| 486 | + port, _ = self.network_dict[key] |
| 487 | + port_dict[port] = port_amplitudes[key] |
| 488 | + # Get the radiation monitor, use first as default |
| 489 | + # if none specified |
| 490 | + if monitor_name is None: |
| 491 | + rad_mon = self.radiation_monitors[0] |
| 492 | + else: |
| 493 | + rad_mon = self.get_radiation_monitor_by_name(monitor_name) |
| 494 | + |
| 495 | + # Create data arrays for holding the superposition of all port power wave amplitudes |
| 496 | + f = list(rad_mon.freqs) |
| 497 | + coords = {"f": f, "port": list(self.matrix_indices_monitor)} |
| 498 | + a_sum = PortDataArray( |
| 499 | + np.zeros((len(f), len(self.matrix_indices_monitor)), dtype=complex), coords=coords |
| 500 | + ) |
| 501 | + b_sum = a_sum.copy() |
| 502 | + # Retrieve associated simulation data |
| 503 | + combined_directivity_data = None |
| 504 | + for port, amplitude in port_dict.items(): |
| 505 | + if amplitude == 0.0: |
| 506 | + continue |
| 507 | + sim_data_port = self.batch_data[self._task_name(port=port)] |
| 508 | + radiation_data = sim_data_port[rad_mon.name] |
| 509 | + |
| 510 | + a, b = self.compute_wave_amplitudes_at_each_port( |
| 511 | + self.port_reference_impedances, sim_data_port, s_param_def="power" |
| 512 | + ) |
| 513 | + # Select a possible subset of frequencies |
| 514 | + a = a.sel(f=f) |
| 515 | + b = b.sel(f=f) |
| 516 | + a_raw = a.sel(port=self.network_index(port)) |
| 517 | + |
| 518 | + if amplitude is None: |
| 519 | + # No scaling performed when amplitude is None |
| 520 | + scaled_directivity_data = sim_data_port[rad_mon.name] |
| 521 | + scale_factor = 1.0 |
| 522 | + else: |
| 523 | + scaled_directivity_data = self._monitor_data_at_port_amplitude( |
| 524 | + port, sim_data_port, radiation_data, amplitude |
| 525 | + ) |
| 526 | + scale_factor = amplitude / a_raw |
| 527 | + a = scale_factor * a |
| 528 | + b = scale_factor * b |
| 529 | + |
| 530 | + # Combine the possibly scaled directivity data and the power wave amplitudes |
| 531 | + if combined_directivity_data is None: |
| 532 | + combined_directivity_data = scaled_directivity_data |
| 533 | + else: |
| 534 | + combined_directivity_data = combined_directivity_data + scaled_directivity_data |
| 535 | + a_sum += a |
| 536 | + b_sum += b |
| 537 | + |
| 538 | + # Compute and add power measures to results |
| 539 | + power_incident = np.real(0.5 * a_sum * np.conj(a_sum)).sum(dim="port") |
| 540 | + power_reflected = np.real(0.5 * b_sum * np.conj(b_sum)).sum(dim="port") |
| 541 | + return AntennaMetricsData.from_directivity_data( |
| 542 | + combined_directivity_data, power_incident, power_reflected |
| 543 | + ) |
| 544 | + |
| 545 | + def _extrude_port_structures(self, sim: Simulation) -> Simulation: |
| 546 | + """ |
| 547 | + Extrude structures intersecting a port plane when a wave port lies on a structure boundary. |
| 548 | +
|
| 549 | + This method checks wave ports with ``extrude_structures==True`` and automatically extends the boundary structures |
| 550 | + to PEC plates associated with internal absorbers in the direction opposite to the mode source. |
| 551 | + This ensures that mode sources and internal absorbers are fully contained within the extrusion. |
| 552 | +
|
| 553 | + Parameters |
| 554 | + ---------- |
| 555 | + sim : Simulation |
| 556 | + Simulation object containing mode sources, internal absorbers, and monitors, |
| 557 | + after mesh overrides and snapping points are applied. |
| 558 | +
|
| 559 | + Returns |
| 560 | + ------- |
| 561 | + Simulation |
| 562 | + Updated simulation with extruded structures added to ``simulation.structures``. |
| 563 | + """ |
| 564 | + |
| 565 | + # get coordinated of the simulation grid |
| 566 | + coords = sim.grid.boundaries.to_list |
| 567 | + |
| 568 | + mode_sources = [] |
| 569 | + |
| 570 | + # get all mode sources from TerminalComponentModeler that correspond to ports with ``extrude_structures`` flag set to ``True``. |
| 571 | + for port in self.ports: |
| 572 | + if isinstance(port, WavePort) and port.extrude_structures: |
| 573 | + # update center here (example) |
| 574 | + inj_axis = port.injection_axis |
| 575 | + |
| 576 | + port_center = list(port.center) |
| 577 | + |
| 578 | + idx = np.abs(port_center[inj_axis] - coords[inj_axis]).argmin() |
| 579 | + port_center[inj_axis] = coords[inj_axis][idx] |
| 580 | + |
| 581 | + port = port.updated_copy(center=tuple(port_center)) |
| 582 | + |
| 583 | + mode_src_pos = port.center[port.injection_axis] + self._shift_value_signed(port) |
| 584 | + |
| 585 | + # then convert to source |
| 586 | + mode_sources.append(port.to_source(self._source_time, snap_center=mode_src_pos)) |
| 587 | + |
| 588 | + # clip indices to a valid range |
| 589 | + def _clip(i, lo, hi): |
| 590 | + return int(max(lo, min(hi, i))) |
| 591 | + |
| 592 | + new_structures = [] |
| 593 | + |
| 594 | + # loop over individual mode sources associated with waveports |
| 595 | + for mode in mode_sources: |
| 596 | + direction = mode.direction |
| 597 | + inj_axis = mode.injection_axis |
| 598 | + span_indx = np.array(sim.grid.discretize_inds(mode)) |
| 599 | + |
| 600 | + target_val = mode.center[inj_axis] |
| 601 | + |
| 602 | + bnd_coords = coords[inj_axis] |
| 603 | + |
| 604 | + offset = mode.frame.length + sim.internal_absorbers[0].grid_shift + 1 |
| 605 | + |
| 606 | + # get total number of boundaries along injection direction |
| 607 | + n_axis = len(bnd_coords) - 1 |
| 608 | + |
| 609 | + # define indicies of cells to be used for extrusion |
| 610 | + if direction == "+": |
| 611 | + idx = np.searchsorted(bnd_coords, target_val, side="left") - 1 |
| 612 | + left = _clip(idx - 1, 0, n_axis) |
| 613 | + right = _clip(idx + offset, 0, n_axis) |
| 614 | + else: |
| 615 | + idx = np.searchsorted(bnd_coords, target_val, side="right") |
| 616 | + left = _clip(idx - offset, 0, n_axis) |
| 617 | + right = _clip(idx + 1, 0, n_axis) |
| 618 | + |
| 619 | + # get indices for extrusion box boundaries |
| 620 | + span_indx[inj_axis][0] = left |
| 621 | + span_indx[inj_axis][1] = right |
| 622 | + |
| 623 | + # get bounding box bounds |
| 624 | + box_bounds = [[c[beg], c[end]] for c, (beg, end) in zip(coords, span_indx)] |
| 625 | + |
| 626 | + # construct extrusion bounding box from bounds |
| 627 | + box = Box.from_bounds(*np.transpose(box_bounds)) |
| 628 | + |
| 629 | + # get bounding box faces orthogonal to extrusion direction |
| 630 | + slices = box.surfaces(box.size, box.center) |
| 631 | + slice_plane_left = slices[2 * inj_axis] |
| 632 | + slice_plane_right = slices[2 * inj_axis + 1] |
| 633 | + |
| 634 | + # loop over structures and extrude those that intersect a waveport plane |
| 635 | + for structure in sim.structures: |
| 636 | + # get geometries that intersect the plane on which the waveport is defined |
| 637 | + left_geom = slice_plane_left.intersections_with(structure.geometry) |
| 638 | + right_geom = slice_plane_right.intersections_with(structure.geometry) |
| 639 | + shapely_geom = left_geom or right_geom or [] |
| 640 | + |
| 641 | + new_geoms = [] |
| 642 | + |
| 643 | + # loop over identified geometries and extrude them |
| 644 | + for polygon in shapely_geom: |
| 645 | + # construct outer shell of an extruded geometry first |
| 646 | + exterior_vertices = np.array(polygon.exterior.coords) |
| 647 | + outer_shell = PolySlab( |
| 648 | + axis=inj_axis, slab_bounds=box_bounds[inj_axis], vertices=exterior_vertices |
| 649 | + ) |
| 650 | + |
| 651 | + # construct innner shells that represent holes |
| 652 | + hole_polyslabs = [ |
| 653 | + PolySlab( |
| 654 | + axis=inj_axis, |
| 655 | + slab_bounds=box_bounds[inj_axis], |
| 656 | + vertices=np.array(hole.coords), |
| 657 | + ) |
| 658 | + for hole in polygon.interiors |
| 659 | + ] |
| 660 | + |
| 661 | + # construct final geometry by removing inner holes from outer shell |
| 662 | + if hole_polyslabs: |
| 663 | + holes = GeometryGroup(geometries=hole_polyslabs) |
| 664 | + extruded_slab_new = ClipOperation( |
| 665 | + operation="difference", geometry_a=outer_shell, geometry_b=holes |
| 666 | + ) |
| 667 | + else: |
| 668 | + extruded_slab_new = outer_shell |
| 669 | + |
| 670 | + new_geoms.append(extruded_slab_new) |
| 671 | + |
| 672 | + new_geoms.append(structure.geometry) |
| 673 | + |
| 674 | + new_struct = Structure( |
| 675 | + geometry=GeometryGroup(geometries=new_geoms), medium=structure.medium |
| 676 | + ) |
| 677 | + new_structures.append(new_struct) |
| 678 | + |
| 679 | + # return simulation with added extruded structures |
| 680 | + return sim.updated_copy(grid_spec=GridSpec.from_grid(sim.grid), structures=new_structures) |
| 681 | + |
446 | 682 |
|
447 | 683 | TerminalComponentModeler.update_forward_refs()
|
| 684 | + |
0 commit comments