|
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.frequency_extrapolation import ( |
@@ -291,7 +292,9 @@ def sim_dict(self) -> SimulationMap: |
291 | 292 | # Now, create simulations with wave port sources and mode solver monitors for computing port modes |
292 | 293 | for network_index in self.matrix_indices_run_sim: |
293 | 294 | task_name, sim_with_src = self._add_source_to_sim(network_index) |
294 | | - sim_dict[task_name] = sim_with_src |
| 295 | + |
| 296 | + # extrude structures if necessary and update simulation |
| 297 | + sim_dict[task_name] = self._extrude_port_structures(sim_with_src) |
295 | 298 |
|
296 | 299 | # Check final simulations for grid size at ports |
297 | 300 | for _, sim in sim_dict.items(): |
@@ -514,5 +517,239 @@ def get_radiation_monitor_by_name(self, monitor_name: str) -> DirectivityMonitor |
514 | 517 | return monitor |
515 | 518 | raise Tidy3dKeyError(f"No radiation monitor named '{monitor_name}'.") |
516 | 519 |
|
| 520 | + def get_antenna_metrics_data( |
| 521 | + self, |
| 522 | + port_amplitudes: Optional[dict[str, complex]] = None, |
| 523 | + monitor_name: Optional[str] = None, |
| 524 | + ) -> AntennaMetricsData: |
| 525 | + """Calculate antenna parameters using superposition of fields from multiple port excitations. |
| 526 | +
|
| 527 | + The method computes the radiated far fields and port excitation power wave amplitudes |
| 528 | + for a superposition of port excitations, which can be used to analyze antenna radiation |
| 529 | + characteristics. |
| 530 | +
|
| 531 | + Parameters |
| 532 | + ---------- |
| 533 | + port_amplitudes : dict[str, complex] = None |
| 534 | + Dictionary mapping port names to their desired excitation amplitudes, ``a``. For each port, |
| 535 | + :math:`\\frac{1}{2}|a|^2` represents the incident power from that port into the system. |
| 536 | + If ``None``, uses only the first port without any scaling of the raw simulation data. |
| 537 | + When ``None`` is passed as a port amplitude, the raw simulation data is used for that port. |
| 538 | + Note that in this method ``a`` represents the incident wave amplitude |
| 539 | + using the power wave definition in [2]. |
| 540 | + monitor_name : str = None |
| 541 | + Name of the :class:`.DirectivityMonitor` to use for calculating far fields. |
| 542 | + If None, uses the first monitor in `radiation_monitors`. |
| 543 | +
|
| 544 | + Returns |
| 545 | + ------- |
| 546 | + :class:`.AntennaMetricsData` |
| 547 | + Container with antenna parameters including directivity, gain, and radiation efficiency, |
| 548 | + computed from the superposition of fields from all excited ports. |
| 549 | + """ |
| 550 | + # Use the first port as default if none specified |
| 551 | + if port_amplitudes is None: |
| 552 | + port_amplitudes = {self.ports[0].name: None} |
| 553 | + |
| 554 | + # Check port names, and create map from port to amplitude |
| 555 | + port_dict = {} |
| 556 | + for key in port_amplitudes.keys(): |
| 557 | + port, _ = self.network_dict[key] |
| 558 | + port_dict[port] = port_amplitudes[key] |
| 559 | + # Get the radiation monitor, use first as default |
| 560 | + # if none specified |
| 561 | + if monitor_name is None: |
| 562 | + rad_mon = self.radiation_monitors[0] |
| 563 | + else: |
| 564 | + rad_mon = self.get_radiation_monitor_by_name(monitor_name) |
| 565 | + |
| 566 | + # Create data arrays for holding the superposition of all port power wave amplitudes |
| 567 | + f = list(rad_mon.freqs) |
| 568 | + coords = {"f": f, "port": list(self.matrix_indices_monitor)} |
| 569 | + a_sum = PortDataArray( |
| 570 | + np.zeros((len(f), len(self.matrix_indices_monitor)), dtype=complex), coords=coords |
| 571 | + ) |
| 572 | + b_sum = a_sum.copy() |
| 573 | + # Retrieve associated simulation data |
| 574 | + combined_directivity_data = None |
| 575 | + for port, amplitude in port_dict.items(): |
| 576 | + if amplitude == 0.0: |
| 577 | + continue |
| 578 | + sim_data_port = self.batch_data[self._task_name(port=port)] |
| 579 | + radiation_data = sim_data_port[rad_mon.name] |
| 580 | + |
| 581 | + a, b = self.compute_wave_amplitudes_at_each_port( |
| 582 | + self.port_reference_impedances, sim_data_port, s_param_def="power" |
| 583 | + ) |
| 584 | + # Select a possible subset of frequencies |
| 585 | + a = a.sel(f=f) |
| 586 | + b = b.sel(f=f) |
| 587 | + a_raw = a.sel(port=self.network_index(port)) |
| 588 | + |
| 589 | + if amplitude is None: |
| 590 | + # No scaling performed when amplitude is None |
| 591 | + scaled_directivity_data = sim_data_port[rad_mon.name] |
| 592 | + scale_factor = 1.0 |
| 593 | + else: |
| 594 | + scaled_directivity_data = self._monitor_data_at_port_amplitude( |
| 595 | + port, sim_data_port, radiation_data, amplitude |
| 596 | + ) |
| 597 | + scale_factor = amplitude / a_raw |
| 598 | + a = scale_factor * a |
| 599 | + b = scale_factor * b |
| 600 | + |
| 601 | + # Combine the possibly scaled directivity data and the power wave amplitudes |
| 602 | + if combined_directivity_data is None: |
| 603 | + combined_directivity_data = scaled_directivity_data |
| 604 | + else: |
| 605 | + combined_directivity_data = combined_directivity_data + scaled_directivity_data |
| 606 | + a_sum += a |
| 607 | + b_sum += b |
| 608 | + |
| 609 | + # Compute and add power measures to results |
| 610 | + power_incident = np.real(0.5 * a_sum * np.conj(a_sum)).sum(dim="port") |
| 611 | + power_reflected = np.real(0.5 * b_sum * np.conj(b_sum)).sum(dim="port") |
| 612 | + return AntennaMetricsData.from_directivity_data( |
| 613 | + combined_directivity_data, power_incident, power_reflected |
| 614 | + ) |
| 615 | + |
| 616 | + def _extrude_port_structures(self, sim: Simulation) -> Simulation: |
| 617 | + """ |
| 618 | + Extrude structures intersecting a port plane when a wave port lies on a structure boundary. |
| 619 | +
|
| 620 | + This method checks wave ports with ``extrude_structures==True`` and automatically extends the boundary structures |
| 621 | + to PEC plates associated with internal absorbers in the direction opposite to the mode source. |
| 622 | + This ensures that mode sources and internal absorbers are fully contained within the extrusion. |
| 623 | +
|
| 624 | + Parameters |
| 625 | + ---------- |
| 626 | + sim : Simulation |
| 627 | + Simulation object containing mode sources, internal absorbers, and monitors, |
| 628 | + after mesh overrides and snapping points are applied. |
| 629 | +
|
| 630 | + Returns |
| 631 | + ------- |
| 632 | + Simulation |
| 633 | + Updated simulation with extruded structures added to ``simulation.structures``. |
| 634 | + """ |
| 635 | + |
| 636 | + # get coordinated of the simulation grid |
| 637 | + coords = sim.grid.boundaries.to_list |
| 638 | + |
| 639 | + mode_sources = [] |
| 640 | + |
| 641 | + # get all mode sources from TerminalComponentModeler that correspond to ports with ``extrude_structures`` flag set to ``True``. |
| 642 | + for port in self.ports: |
| 643 | + if isinstance(port, WavePort) and port.extrude_structures: |
| 644 | + # update center here (example) |
| 645 | + inj_axis = port.injection_axis |
| 646 | + |
| 647 | + port_center = list(port.center) |
| 648 | + |
| 649 | + idx = np.abs(port_center[inj_axis] - coords[inj_axis]).argmin() |
| 650 | + port_center[inj_axis] = coords[inj_axis][idx] |
| 651 | + |
| 652 | + port = port.updated_copy(center=tuple(port_center)) |
| 653 | + |
| 654 | + mode_src_pos = port.center[port.injection_axis] + self._shift_value_signed(port) |
| 655 | + |
| 656 | + # then convert to source |
| 657 | + mode_sources.append(port.to_source(self._source_time, snap_center=mode_src_pos)) |
| 658 | + |
| 659 | + # clip indices to a valid range |
| 660 | + def _clip(i, lo, hi): |
| 661 | + return int(max(lo, min(hi, i))) |
| 662 | + |
| 663 | + new_structures = [] |
| 664 | + |
| 665 | + # loop over individual mode sources associated with waveports |
| 666 | + for mode in mode_sources: |
| 667 | + direction = mode.direction |
| 668 | + inj_axis = mode.injection_axis |
| 669 | + span_indx = np.array(sim.grid.discretize_inds(mode)) |
| 670 | + |
| 671 | + target_val = mode.center[inj_axis] |
| 672 | + |
| 673 | + bnd_coords = coords[inj_axis] |
| 674 | + |
| 675 | + offset = mode.frame.length + sim.internal_absorbers[0].grid_shift + 1 |
| 676 | + |
| 677 | + # get total number of boundaries along injection direction |
| 678 | + n_axis = len(bnd_coords) - 1 |
| 679 | + |
| 680 | + # define indicies of cells to be used for extrusion |
| 681 | + if direction == "+": |
| 682 | + idx = np.searchsorted(bnd_coords, target_val, side="left") - 1 |
| 683 | + left = _clip(idx - 1, 0, n_axis) |
| 684 | + right = _clip(idx + offset, 0, n_axis) |
| 685 | + else: |
| 686 | + idx = np.searchsorted(bnd_coords, target_val, side="right") |
| 687 | + left = _clip(idx - offset, 0, n_axis) |
| 688 | + right = _clip(idx + 1, 0, n_axis) |
| 689 | + |
| 690 | + # get indices for extrusion box boundaries |
| 691 | + span_indx[inj_axis][0] = left |
| 692 | + span_indx[inj_axis][1] = right |
| 693 | + |
| 694 | + # get bounding box bounds |
| 695 | + box_bounds = [[c[beg], c[end]] for c, (beg, end) in zip(coords, span_indx)] |
| 696 | + |
| 697 | + # construct extrusion bounding box from bounds |
| 698 | + box = Box.from_bounds(*np.transpose(box_bounds)) |
| 699 | + |
| 700 | + # get bounding box faces orthogonal to extrusion direction |
| 701 | + slices = box.surfaces(box.size, box.center) |
| 702 | + slice_plane_left = slices[2 * inj_axis] |
| 703 | + slice_plane_right = slices[2 * inj_axis + 1] |
| 704 | + |
| 705 | + # loop over structures and extrude those that intersect a waveport plane |
| 706 | + for structure in sim.structures: |
| 707 | + # get geometries that intersect the plane on which the waveport is defined |
| 708 | + left_geom = slice_plane_left.intersections_with(structure.geometry) |
| 709 | + right_geom = slice_plane_right.intersections_with(structure.geometry) |
| 710 | + shapely_geom = left_geom or right_geom or [] |
| 711 | + |
| 712 | + new_geoms = [] |
| 713 | + |
| 714 | + # loop over identified geometries and extrude them |
| 715 | + for polygon in shapely_geom: |
| 716 | + # construct outer shell of an extruded geometry first |
| 717 | + exterior_vertices = np.array(polygon.exterior.coords) |
| 718 | + outer_shell = PolySlab( |
| 719 | + axis=inj_axis, slab_bounds=box_bounds[inj_axis], vertices=exterior_vertices |
| 720 | + ) |
| 721 | + |
| 722 | + # construct innner shells that represent holes |
| 723 | + hole_polyslabs = [ |
| 724 | + PolySlab( |
| 725 | + axis=inj_axis, |
| 726 | + slab_bounds=box_bounds[inj_axis], |
| 727 | + vertices=np.array(hole.coords), |
| 728 | + ) |
| 729 | + for hole in polygon.interiors |
| 730 | + ] |
| 731 | + |
| 732 | + # construct final geometry by removing inner holes from outer shell |
| 733 | + if hole_polyslabs: |
| 734 | + holes = GeometryGroup(geometries=hole_polyslabs) |
| 735 | + extruded_slab_new = ClipOperation( |
| 736 | + operation="difference", geometry_a=outer_shell, geometry_b=holes |
| 737 | + ) |
| 738 | + else: |
| 739 | + extruded_slab_new = outer_shell |
| 740 | + |
| 741 | + new_geoms.append(extruded_slab_new) |
| 742 | + |
| 743 | + new_geoms.append(structure.geometry) |
| 744 | + |
| 745 | + new_struct = Structure( |
| 746 | + geometry=GeometryGroup(geometries=new_geoms), medium=structure.medium |
| 747 | + ) |
| 748 | + new_structures.append(new_struct) |
| 749 | + |
| 750 | + # return simulation with added extruded structures |
| 751 | + return sim.updated_copy(grid_spec=GridSpec.from_grid(sim.grid), structures=new_structures) |
| 752 | + |
517 | 753 |
|
518 | 754 | TerminalComponentModeler.update_forward_refs() |
| 755 | + |
0 commit comments