"""Monitor level data, store the DataArrays associated with a single heat-charge monitor."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import numpy as np
from pydantic import Field, model_validator
from tidy3d.components.data.data_array import (
IndexedFieldVoltageDataArray,
IndexedVoltageDataArray,
PointDataArray,
SpatialDataArray,
SteadyVoltageDataArray,
)
from tidy3d.components.data.utils import TetrahedralGridDataset, TriangularGridDataset
from tidy3d.components.tcad.data.monitor_data.abstract import HeatChargeMonitorData
from tidy3d.components.tcad.monitors.charge import (
SteadyCapacitanceMonitor,
SteadyChargeResidualMonitor,
SteadyCurrentDensityMonitor,
SteadyElectricFieldMonitor,
SteadyEnergyBandMonitor,
SteadyFreeCarrierMonitor,
SteadyPotentialMonitor,
)
from tidy3d.components.types import TYPE_TAG_STR
from tidy3d.components.types.base import discriminated_union
from tidy3d.components.viz import add_ax_if_none
from tidy3d.exceptions import DataError
if TYPE_CHECKING:
from tidy3d.compat import Self
from tidy3d.components.types import Ax
FieldDataset = (
discriminated_union(TriangularGridDataset | TetrahedralGridDataset) | SpatialDataArray
)
UnstructuredFieldType = discriminated_union(TriangularGridDataset | TetrahedralGridDataset)
[docs]
class SteadyPotentialData(HeatChargeMonitorData):
"""Stores electric potential :math:`\\psi` from a charge simulation."""
monitor: SteadyPotentialMonitor = Field(
title="Electric potential monitor",
description="Electric potential monitor associated with a `charge` simulation.",
)
potential: FieldDataset | None = Field(
None,
title="Electric potential series",
description="Contains the electric potential series.",
json_schema_extra={"units": "V"},
)
@property
def field_components(self) -> dict[str, FieldDataset | None]:
"""Maps the field components to their associated data."""
return {"potential": self.potential}
[docs]
class SteadyFreeCarrierData(HeatChargeMonitorData):
"""
Stores free-carrier concentration in charge simulations.
Notes
-----
This data contains the carrier concentrations: the amount of electrons and holes per unit volume as defined in the
``monitor``.
"""
monitor: SteadyFreeCarrierMonitor = Field(
title="Free carrier monitor",
description="Free carrier data associated with a Charge simulation.",
)
electrons: UnstructuredFieldType | None = Field(
None,
title="Electrons series",
description=r"Contains the computed electrons concentration :math:`n`.",
json_schema_extra={"units": "1/cm^3"},
)
# n = electrons
holes: UnstructuredFieldType | None = Field(
None,
title="Holes series",
description=r"Contains the computed holes concentration :math:`p`.",
json_schema_extra={"units": "1/cm^3"},
)
# p = holes
@property
def field_components(self) -> dict[str, UnstructuredFieldType | None]:
"""Maps the field components to their associated data."""
return {"electrons": self.electrons, "holes": self.holes}
[docs]
@model_validator(mode="after")
def check_correct_data_type(self) -> Self:
"""Issue error if incorrect data type is used"""
field_data = {field: getattr(self, field) for field in ["electrons", "holes"]}
for field, data in field_data.items():
if isinstance(data, TetrahedralGridDataset) or isinstance(data, TriangularGridDataset):
if not isinstance(data.values, IndexedVoltageDataArray):
raise ValueError(
f"In the data associated with monitor {self.monitor}, the "
f"field {field} does not contain data associated to any voltage value."
)
return self
[docs]
class SteadyEnergyBandData(HeatChargeMonitorData):
"""
Stores energy bands in charge simulations.
Notes
-----
This data contains the energy bands data [eV]:
.. list-table::
:widths: 25 25 75
:header-rows: 1
* - Symbol
- Parameter Name
- Description
* - :math:`E_c`
- ``Ec``
- Energy of the bottom of the conduction band
* - :math:`E_v`
- ``Ev``
- Energy of the top of the valence band
* - :math:`E_i`
- ``Ei``
- Intrinsic Fermi level
* - :math:`E_{fn}`
- ``Efn``
- Quasi-Fermi level for electrons
* - :math:`E_{fp}`
- ``Efp``
- Quasi-Fermi level for holes
as defined in the ``monitor``.
"""
monitor: SteadyEnergyBandMonitor = Field(
title="Energy band monitor",
description="Energy bands data associated with a Charge simulation.",
)
Ec: UnstructuredFieldType | None = Field(
None,
title="Conduction band series",
description="Contains the computed energy of the bottom of the conduction band :math:`E_c`.",
json_schema_extra={"units": "eV"},
)
Ev: UnstructuredFieldType | None = Field(
None,
title="Valence band series",
description="Contains the computed energy of the top of the valence band :math:`E_v`.",
json_schema_extra={"units": "eV"},
)
Ei: UnstructuredFieldType | None = Field(
None,
title="Intrinsic Fermi level series",
description="Contains the computed intrinsic Fermi level for the material :math:`E_i`.",
json_schema_extra={"units": "eV"},
)
Efn: UnstructuredFieldType | None = Field(
None,
title="Electron's quasi-Fermi level series",
description="Contains the computed quasi-Fermi level for electrons :math:`E_{fn}`.",
json_schema_extra={"units": "eV"},
)
Efp: UnstructuredFieldType | None = Field(
None,
title="Hole's quasi-Fermi level series",
description="Contains the computed quasi-Fermi level for holes :math:`E_{fp}`.",
json_schema_extra={"units": "eV"},
)
@property
def field_components(self) -> dict[str, UnstructuredFieldType | None]:
"""Maps the field components to their associated data."""
return {"Ec": self.Ec, "Ev": self.Ev, "Ei": self.Ei, "Efn": self.Efn, "Efp": self.Efp}
[docs]
@model_validator(mode="after")
def check_correct_data_type(self) -> Self:
"""Issue error if incorrect data type is used"""
field_data = {field: getattr(self, field) for field in ["Ec", "Ev", "Ei", "Efn", "Efp"]}
for field, data in field_data.items():
if isinstance(data, TetrahedralGridDataset) or isinstance(data, TriangularGridDataset):
if not isinstance(data.values, IndexedVoltageDataArray):
raise ValueError(
f"In the data associated with monitor {self.monitor}, the "
f"field {field} does not contain data associated to any voltage value."
)
return self
[docs]
@add_ax_if_none
def plot(self, ax: Ax = None, **sel_kwargs: Any) -> Ax:
"""Plot the 1D cross-section of the energy bandgap diagram.
Parameters
----------
ax : matplotlib.axes._subplots.Axes = None
matplotlib axes to plot on, if not specified, one is created.
sel_kwargs : keyword arguments used to perform ``.sel()`` selection in the monitor data.
These kwargs can select over the spatial dimensions (``x``, ``y``, or ``z``)
and the bias voltage (``voltage``).
For the plotting to work appropriately, the resulting data after selection must contain
only one coordinate with len > 1.
Furthermore, these should be spatial coordinates (``x``, ``y``, or ``z``).
Returns
-------
matplotlib.axes._subplots.Axes
The supplied or created matplotlib axes.
"""
selection_data = {}
if ("voltage" not in sel_kwargs) and (self.Ec.values.coords.sizes["voltage"] > 1):
raise DataError(
"'voltage' is not selected for the plot with multiple voltage data points."
)
selection_data = {coord: sel_kwargs[coord] for coord in "xyz" if coord in sel_kwargs.keys()}
N_coords = len(selection_data.keys())
if "voltage" in sel_kwargs:
selection_data["voltage"] = sel_kwargs["voltage"]
if isinstance(self.Ec, TetrahedralGridDataset):
if N_coords != 2:
raise DataError(
"2 spatial coordinate values have to be defined to plot the 1D cross-section figure for a 3D dataset."
)
elif isinstance(self.Ec, TriangularGridDataset):
if N_coords != 1:
raise DataError(
"1 spatial coordinate value has to be defined to plot the 1D cross-section figure for a 2D dataset."
)
for index, coord_name in enumerate(["x", "y", "z"]):
if coord_name in selection_data:
axis = index
continue
if axis == self.Ec.normal_axis:
raise DataError(
f"Triangular grid (normal: {self.Ec.normal_axis}) cannot be sliced by a parallel plane."
)
Ec_data = self.Ec
Ev_data = self.Ev
Ei_data = self.Ei
Efn_data = self.Efn
Efp_data = self.Efp
for coord_name, coord_val in selection_data.items():
Ec_data = Ec_data.sel(**{coord_name: coord_val}, method="nearest")
Ev_data = Ev_data.sel(**{coord_name: coord_val}, method="nearest")
Ei_data = Ei_data.sel(**{coord_name: coord_val}, method="nearest")
Efn_data = Efn_data.sel(**{coord_name: coord_val}, method="nearest")
Efp_data = Efp_data.sel(**{coord_name: coord_val}, method="nearest")
Ec_data.plot(ax=ax, label="Ec")
Ev_data.plot(ax=ax, label="Ev")
Ei_data.plot(ax=ax, label="Ei")
Efn_data.plot(ax=ax, label="Efn")
Efp_data.plot(ax=ax, label="Efp")
ax.legend()
return ax
[docs]
class SteadyCapacitanceData(HeatChargeMonitorData):
"""
Class that stores capacitance data from a Charge simulation.
Notes
-----
The small signal-capacitance of electrons :math:`C_n` and holes :math:`C_p` is computed from the charge due to electrons :math:`Q_n` and holes :math:`Q_p` at an applied voltage :math:`V` at a voltage difference
:math:`\\Delta V` between two simulations.
.. math::
C_{n,p} = \\frac{Q_{n,p}(V + \\Delta V) - Q_{n,p}(V)}{\\Delta V}
This is only computed when a voltage source with more than two sources is included within the simulation and determines the :math:`\\Delta V`.
"""
monitor: SteadyCapacitanceMonitor = Field(
title="Capacitance monitor",
description="Capacitance data associated with a Charge simulation.",
)
hole_capacitance: SteadyVoltageDataArray | None = Field(
None,
title="Hole capacitance",
description="Small signal capacitance :math:`(\\frac{dQ_p}{dV})` associated to the monitor. "
"Units: fF (3D) or fF/μm (2D, per unit length).",
json_schema_extra={"units": "fF"},
)
# C_p = hole_capacitance
electron_capacitance: SteadyVoltageDataArray | None = Field(
None,
title="Electron capacitance",
description="Small signal capacitance :math:`(\\frac{dQn}{dV})` associated to the monitor. "
"Units: fF (3D) or fF/μm (2D, per unit length).",
json_schema_extra={"units": "fF"},
)
# C_n = electron_capacitance
@property
def field_components(self) -> dict[str, UnstructuredFieldType]:
"""Maps the field components to their associated data."""
return {}
@property
def symmetry_expanded_copy(self) -> SteadyCapacitanceData:
"""Return copy of self with symmetry applied."""
num_symmetries = np.sum(np.array([1 if d > 0 else 0 for d in self.symmetry]))
scaling_factor = np.power(2, num_symmetries)
if self.hole_capacitance is None:
new_hole_capacitance = None
else:
new_values = self.hole_capacitance.values * scaling_factor
new_hole_capacitance = SteadyVoltageDataArray(
data=new_values, coords=self.hole_capacitance.coords
)
if self.electron_capacitance is None:
new_electron_capacitance = None
else:
new_values = self.electron_capacitance.values * scaling_factor
new_electron_capacitance = SteadyVoltageDataArray(
data=new_values, coords=self.electron_capacitance.coords
)
return self.updated_copy(
hole_capacitance=new_hole_capacitance,
electron_capacitance=new_electron_capacitance,
symmetry=(0, 0, 0),
deep=False,
validate=False,
)
[docs]
class SteadyElectricFieldData(HeatChargeMonitorData):
"""
Stores electric field :math:`\\vec{E}` from a Charge/Conduction simulation.
Notes
-----
The electric field is computed as the negative gradient of the electric potential :math:`\\vec{E} = -\\nabla \\psi`.
It is given in units of :math:`V/\\mu m` (Volts per micrometer).
"""
monitor: SteadyElectricFieldMonitor = Field(
title="Electric field monitor",
description="Electric field data associated with a Charge/Conduction simulation.",
)
E: UnstructuredFieldType | None = Field(
None,
title="Electric field",
description="Contains the computed electric field.",
json_schema_extra={"units": ":math:`V/\\mu m`"},
)
@property
def field_components(self) -> dict[str, UnstructuredFieldType]:
"""Maps the field components to their associated data."""
return {"E": self.E}
[docs]
@model_validator(mode="after")
def check_correct_data_type(self) -> Self:
"""Issue error if incorrect data type is used"""
if isinstance(self.E, TetrahedralGridDataset) or isinstance(self.E, TriangularGridDataset):
if not isinstance(self.E.values, (IndexedFieldVoltageDataArray, PointDataArray)):
raise ValueError(
f"The data associated with monitor {self.monitor.name} must contain a field. This can be "
"defined with 'IndexedFieldVoltageDataArray' or 'PointDataArray'."
)
return self
[docs]
class SteadyChargeResidualData(HeatChargeMonitorData):
"""Stores per-node residual snapshots from a Charge simulation.
Notes
-----
``residual_potential``, ``residual_electrons``, ``residual_holes`` (and
``residual_temperature`` when the thermal solver is active) are the
per-node signed residual components of each governing equation. They are
dimensionless and on the same scale as the simulation's convergence
tolerance, so the nodes with the largest magnitude (approaching or
exceeding that tolerance) are where the solution least satisfies the
equations (the least-converged regions).
"""
monitor: SteadyChargeResidualMonitor = Field(
title="Residual monitor",
description="Per-node residual monitor associated with a Charge simulation.",
)
residual_potential: UnstructuredFieldType = Field(
title="Potential residual",
description="Signed residual of the Poisson equation :math:`R_\\psi`.",
json_schema_extra={"units": "dimensionless"},
)
residual_electrons: UnstructuredFieldType = Field(
title="Electron continuity residual",
description="Signed residual of the electron continuity (carrier conservation) "
"equation :math:`R_n`.",
json_schema_extra={"units": "dimensionless"},
)
residual_holes: UnstructuredFieldType = Field(
title="Hole continuity residual",
description="Signed residual of the hole continuity (carrier conservation) "
"equation :math:`R_p`.",
json_schema_extra={"units": "dimensionless"},
)
residual_temperature: UnstructuredFieldType | None = Field(
None,
title="Thermal residual",
description="Signed residual of the heat equation :math:`R_T`. "
"Present only when the thermal solver is active.",
json_schema_extra={"units": "dimensionless"},
)
@property
def field_components(self) -> dict[str, UnstructuredFieldType | None]:
"""Maps the field components to their associated data."""
components: dict[str, UnstructuredFieldType | None] = {
"residual_potential": self.residual_potential,
"residual_electrons": self.residual_electrons,
"residual_holes": self.residual_holes,
}
if self.residual_temperature is not None:
components["residual_temperature"] = self.residual_temperature
return components
[docs]
class SteadyCurrentDensityData(HeatChargeMonitorData):
"""
Stores current density :math:`\\vec{J}` from a Charge/Conduction simulation.
Units: :math:`A/\\mu m^2` (3D) or :math:`A/\\mu m` (2D, per unit length).
"""
monitor: SteadyCurrentDensityMonitor = Field(
title="Current density monitor",
description="Current density data associated with a Charge/Conduction simulation.",
)
J: UnstructuredFieldType | None = Field(
None,
title="Current density",
description="Contains the computed current density.",
discriminator=TYPE_TAG_STR,
json_schema_extra={"units": ":math:`A/\\mu m^2`"},
)
@property
def field_components(self) -> dict[str, UnstructuredFieldType]:
"""Maps the field components to their associated data."""
return {"J": self.J}
[docs]
@model_validator(mode="after")
def check_correct_data_type(self) -> Self:
"""Issue error if incorrect data type is used"""
mnt = self.monitor
J = self.J
if isinstance(J, TetrahedralGridDataset) or isinstance(J, TriangularGridDataset):
AcceptedTypes = (IndexedFieldVoltageDataArray, PointDataArray)
if not isinstance(J.values, AcceptedTypes):
raise ValueError(
f"In the data associated with monitor {mnt}, must contain a field. This can be "
"defined with IndexedFieldVoltageDataArray or PointDataArray."
)
return self