diff --git a/tests/test_components/test_heat_charge.py b/tests/test_components/test_heat_charge.py index 985e6d5044..f48fc55d09 100644 --- a/tests/test_components/test_heat_charge.py +++ b/tests/test_components/test_heat_charge.py @@ -1336,6 +1336,14 @@ def test_charge_simulation( ) _ = sim.updated_copy(boundary_spec=[new_bc_p, bc_n]) + # test error is raised with 1D monitors + with pytest.raises(pd.ValidationError): + _ = sim.updated_copy( + monitors=[ + charge_global_mnt.updated_copy(size=(1, 0, 0)), + ] + ) + def test_doping_distributions(self): """Test doping distributions.""" # Implementation needed @@ -2039,3 +2047,110 @@ def test_heat_conduction_simulations(): with pytest.raises(pd.ValidationError): # This should error since the conduction simulation doesn't have a monitor _ = sim.updated_copy(monitors=[temp_monitor]) + + +def test_charge_copy_xy(): + """Test weather the function _create_charge_copy_xy works as expected""" + # create a Gaussian doping box + d_box = td.GaussianDoping( + center=(0, 0, 0), + size=(1, 1, 2), + ref_con=1e15, + concentration=1e18, + width=0.1, + source="zmin", + ) + + # create SpatialDataArray doping + doping_da = td.SpatialDataArray( + data=np.random.random((3, 1, 3)), + coords={"x": [0, 1, 2], "y": [0], "z": [0, 1, 2]}, + name="doping_datarray", + ) + + # create a semiconductor medium + semiconductor = td.SemiconductorMedium( + permittivity=11.7, + N_c=1e18, + N_v=1e18, + E_g=1.12, + mobility_n=td.ConstantMobilityModel(mu=1500), + mobility_p=td.ConstantMobilityModel(mu=500), + N_d=doping_da, + N_a=[d_box, d_box], + ) + + # create a structure with the semiconductor medium + structure = td.Structure( + geometry=td.Box(center=(0, 0, 0), size=(1, 2, 3)), + medium=td.MultiPhysicsMedium(charge=semiconductor), + name="test_structure", + ) + + structure2 = td.Structure( + geometry=td.Box(center=(0, 0, 0), size=(1, 2, 3)), + medium=td.MultiPhysicsMedium(charge=td.ChargeInsulatorMedium(permittivity=4.0)), + name="test_structure2", + ) + + # create boundary conditions + bc1 = td.HeatChargeBoundarySpec( + condition=td.VoltageBC(source=td.DCVoltageSource(voltage=[1])), + placement=td.StructureSimulationBoundary(structure="test_structure", surfaces=["z+", "z-"]), + ) + + bc2 = td.HeatChargeBoundarySpec( + condition=td.VoltageBC(source=td.DCVoltageSource(voltage=[0])), + placement=td.SimulationBoundary(surfaces=["z+", "x-"]), + ) + + sim = td.HeatChargeSimulation( + structures=[structure, structure2], + medium=td.Medium(heat_spec=td.FluidMedium()), + size=(2, 0, 2), + center=(0, 0, 0), + boundary_spec=[bc1, bc2], + grid_spec=td.UniformUnstructuredGrid(dl=0.1), + monitors=[ + td.SteadyPotentialMonitor( + center=(0, 0, 0), + size=(td.inf, td.inf, td.inf), + name="potential_monitor", + unstructured=True, + ), + td.SteadyFreeCarrierMonitor( + center=(0, 0, 0), + size=(1, 0, 1), + name="free_carrier_monitor", + unstructured=True, + ), + ], + analysis_spec=td.IsothermalSteadyChargeDCAnalysis( + temperature=300, + tolerance_settings=td.ChargeToleranceSpec(rel_tol=1e5, abs_tol=1e3, max_iters=400), + ), + ) + + with pytest.raises(pd.ValidationError): + # Simulation must be at least 2D + _ = sim.updated_copy(size=(1, 0, 0)) + + changed_sim = sim._create_charge_copy_xy() + + assert changed_sim.size == (2, 2, 0), "Size should be updated to (2, 2, 0)" + + assert list(changed_sim.structures[0].medium.charge.N_d.coords["x"].data) == [0, 1, 2] + assert list(changed_sim.structures[0].medium.charge.N_d.coords["y"].data) == [0, 1, 2] + assert list(changed_sim.structures[0].medium.charge.N_d.coords["z"].data) == [0] + + assert changed_sim.structures[0].medium.charge.N_a[0].source == "ymin" + assert changed_sim.structures[0].medium.charge.N_a[0].size == (1, 2, 1) + + assert changed_sim.structures[0].geometry.size == (1, 3, 2) + + assert changed_sim.boundary_spec[0].placement.surfaces == ("y+", "y-") + + assert changed_sim.boundary_spec[1].placement.surfaces == ("y+", "x-") + + assert changed_sim.monitors[0].size == (td.inf, td.inf, td.inf) + assert changed_sim.monitors[1].size == (1, 1, 0) diff --git a/tidy3d/components/tcad/simulation/heat_charge.py b/tidy3d/components/tcad/simulation/heat_charge.py index d5c90e31cc..f56ad98aff 100644 --- a/tidy3d/components/tcad/simulation/heat_charge.py +++ b/tidy3d/components/tcad/simulation/heat_charge.py @@ -3,6 +3,7 @@ from __future__ import annotations +import copy from enum import Enum from typing import Optional, Union @@ -23,6 +24,7 @@ StructureSimulationBoundary, StructureStructureInterface, ) +from tidy3d.components.data.data_array import SpatialDataArray from tidy3d.components.geometry.base import Box from tidy3d.components.material.tcad.charge import ( ChargeConductorMedium, @@ -42,6 +44,7 @@ HeatBoundarySpec, HeatChargeBoundarySpec, ) +from tidy3d.components.tcad.doping import GaussianDoping from tidy3d.components.tcad.grid import ( DistanceUnstructuredGrid, UniformUnstructuredGrid, @@ -583,6 +586,11 @@ def check_charge_simulation(cls, values): "Currently, Charge simulations support only unstructured monitors. Please set " f"monitor '{mnt.name}' to 'unstructured = True'." ) + zero_dims = mnt.zero_dims + if len(zero_dims) > 1: + raise SetupError( + f"Monitor '{mnt.name}' is a 1D monitor which are currently not supported in Charge." + ) return values @@ -1778,3 +1786,128 @@ def _useHeatSourceFromConductionSim(self): """Returns True if 'HeatFromElectricSource' has been defined.""" return any(isinstance(source, HeatFromElectricSource) for source in self.sources) + + def _create_charge_copy_xy(self) -> HeatChargeSimulation: + """If a Charge simulation is not in the x-y plane we need to rotate it to that plane.""" + + sim_types = self._get_simulation_types() + + if TCADAnalysisTypes.CHARGE not in sim_types: + return self + + zero_dims = self.zero_dims + + if len(zero_dims) > 1: + raise SetupError("Charge simulation can only be 2- or 3-D") + + if len(zero_dims) == 0 or zero_dims[0] == 2: + return self + + def _update_center_size( + zero_dims: list[int], center: list[float], size: list[float] + ) -> tuple[list[float], list[float]]: + """Update center and size based on zero dimensions.""" + new_center = list(center) + new_size = list(size) + + new_center[zero_dims[0]] = center[2] + new_center[2] = center[zero_dims[0]] + + new_size[zero_dims[0]] = size[2] + new_size[2] = size[zero_dims[0]] + + return new_center, new_size + + # check doping boxes + # the following dictionary associates a structure with a modified medium + struct_medium = {} + for structure in self.structures: + if not isinstance(structure.medium.charge, SemiconductorMedium): + struct_medium[structure] = structure.medium + else: + sc_medium = copy.deepcopy(structure.medium.charge) + + dopants = {"N_a": sc_medium.N_a, "N_d": sc_medium.N_d} + for key, dopant in dopants.items(): + if isinstance(dopant, SpatialDataArray): + new_dopant = ( + dopant.rename({"z": "z_tmp"}) + .rename({["x", "y"][zero_dims[0]]: "z"}) + .rename({"z_tmp": ["x", "y"][zero_dims[0]]}) + ) + sc_medium = sc_medium.updated_copy(**{key: new_dopant}) + elif isinstance(dopant, tuple): + new_boxes = [] + for doping_box in dopant: + if isinstance(doping_box, GaussianDoping): + new_center, new_size = _update_center_size( + zero_dims, doping_box.center, doping_box.size + ) + + source = doping_box.source + if "z" in source: + source = source.replace("z", ["x", "y"][zero_dims[0]]) + + new_boxes.append( + doping_box.updated_copy( + center=new_center, size=new_size, source=source + ) + ) + else: + new_boxes.append(doping_box) + sc_medium = sc_medium.updated_copy(**{key: new_boxes}) + struct_medium[structure] = structure.medium.updated_copy(charge=sc_medium) + + # change structures + new_structures = [] + for structure in self.structures: + if not isinstance(structure.geometry, Box): + raise SetupError( + "2D Charge simulations defined in planes other than xy can only use Box geometries, " + ) + else: + new_center, new_size = _update_center_size( + zero_dims, structure.geometry.center, structure.geometry.size + ) + + new_structures.append( + structure.updated_copy( + geometry=structure.geometry.updated_copy(center=new_center, size=new_size), + medium=struct_medium[structure], + ) + ) + + # Boundary conditions + new_boundary_spec = [] + for boundary in self.boundary_spec: + new_placement = boundary.placement + PlacementTypes = (SimulationBoundary, StructureSimulationBoundary) + if isinstance(boundary.placement, PlacementTypes): + surfaces = [] + for s in boundary.placement.surfaces: + if "z" in s: + new_s = s.replace("z", ["x", "y"][zero_dims[0]]) + surfaces.append(new_s) + else: + surfaces.append(s) + new_placement = new_placement.updated_copy(surfaces=surfaces) + + new_boundary_spec.append(boundary.updated_copy(placement=new_placement)) + + # Monitors + new_monitors = [] + for mnt in self.monitors: + new_center, new_size = _update_center_size(zero_dims, mnt.center, mnt.size) + + new_monitors.append(mnt.updated_copy(center=new_center, size=new_size)) + + # simulation size + new_center, new_size = _update_center_size(zero_dims, self.center, self.size) + + return self.updated_copy( + structures=new_structures, + boundary_spec=new_boundary_spec, + size=new_size, + center=new_center, + monitors=new_monitors, + )