Skip to content

Enable bulk PMC material #2502

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `priority` parameter to `web.run()` and related functions to allow vGPU users to set task priority (1-10) in the queue.
- `EMEFieldMonitor` now supports `interval_space`.
- `Simulation.precision` option allows to select `"double"` precision for very high-accuracy results. Note that this is very rarely needed, and doubles the simulation computational weight and correpsondingly FlexCredit cost.
- Added material type `PMCMedium` for perfect magnetic conductor.

### Changed
- Switched to an analytical gradient calculation for spatially-varying pole-residue models (`CustomPoleResidue`).
Expand Down
1 change: 1 addition & 0 deletions docs/api/mediums.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Spatially uniform
tidy3d.Medium
tidy3d.LossyMetalMedium
tidy3d.PECMedium
tidy3d.PMCMedium
tidy3d.FullyAnisotropicMedium

Spatially varying
Expand Down
4 changes: 4 additions & 0 deletions tests/test_components/material/test_multi_physics.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def test_delegated_attributes_work(dummy_optical):

# delegated names resolve
assert mp.is_pec is dummy_optical.is_pec
assert mp.is_pmc is dummy_optical.is_pmc
assert mp._eps_plot == dummy_optical._eps_plot
assert mp.viz_spec == dummy_optical.viz_spec

Expand All @@ -30,6 +31,9 @@ def test_delegated_attribute_without_optical_raises():
with pytest.raises(AttributeError, match=r"optical medium is 'None'"):
_ = mp_no_opt.is_pec

with pytest.raises(AttributeError, match=r"optical medium is 'None'"):
_ = mp_no_opt.is_pmc


def test_has_cached_props(dummy_optical):
mp = td.MultiPhysicsMedium(optical=dummy_optical)
Expand Down
9 changes: 8 additions & 1 deletion tests/test_components/test_medium.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@
MEDIUM = td.Medium()
ANIS_MEDIUM = td.AnisotropicMedium(xx=MEDIUM, yy=MEDIUM, zz=MEDIUM)
PEC = td.PECMedium()
PMC = td.PMCMedium()
PR = td.PoleResidue(poles=[(-1 + 1j, 2 + 2j)])
SM = td.Sellmeier(coeffs=[(1, 2)])
LZ = td.Lorentz(coeffs=[(1, 2, 3)])
DR = td.Drude(coeffs=[(1, 2)])
DB = td.Debye(coeffs=[(1, 2)])
MEDIUMS = [MEDIUM, ANIS_MEDIUM, PEC, PR, SM, LZ, DR, DB]
MEDIUMS = [MEDIUM, ANIS_MEDIUM, PEC, PR, SM, LZ, DR, DB, PMC]

f, AX = plt.subplots()

Expand Down Expand Up @@ -142,6 +143,10 @@ def test_PEC():
_ = td.Structure(geometry=td.Box(size=(1, 1, 1)), medium=td.PEC)


def test_PMC():
_ = td.Structure(geometry=td.Box(size=(1, 1, 1)), medium=td.PMC)


def test_lossy_metal():
# frequency_range shouldn't be None
with pytest.raises(pydantic.ValidationError):
Expand Down Expand Up @@ -406,6 +411,8 @@ def test_n_cfl():
assert material.n_cfl == 2
# PEC
assert PEC.n_cfl == 1
# PMC
assert PMC.n_cfl == 1
# anisotropic
material = td.AnisotropicMedium(xx=MEDIUM, yy=td.Medium(permittivity=4), zz=MEDIUM)
assert material.n_cfl == 1
Expand Down
4 changes: 4 additions & 0 deletions tests/test_components/test_time_modulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ def test_unsupported_modulated_medium_types():
with pytest.raises(pydantic.ValidationError):
td.PECMedium(modulation_spec=modulation_spec)

# PMC cannot be modulated
with pytest.raises(pydantic.ValidationError):
td.PMCMedium(modulation_spec=modulation_spec)

# For Anisotropic medium, one should modulate the components, not the whole medium
with pytest.raises(pydantic.ValidationError):
td.AnisotropicMedium(
Expand Down
8 changes: 8 additions & 0 deletions tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,10 @@ def make_custom_data(lims, unstructured):
geometry=td.Box(size=(1, 1, 1), center=(-1, 0, 0)),
medium=td.AnisotropicMedium(xx=td.PEC, yy=td.Medium(), zz=td.Medium()),
),
td.Structure(
geometry=td.Box(size=(1, 1, 1), center=(-1, 0, 0)),
medium=td.AnisotropicMedium(xx=td.PMC, yy=td.Medium(), zz=td.Medium()),
),
# Test a fully anistropic medium
td.Structure(
geometry=td.Box(size=(1, 1, 1), center=(-1, 0, 0)),
Expand All @@ -485,6 +489,10 @@ def make_custom_data(lims, unstructured):
medium=td.PEC,
name="pec_group",
),
td.Structure(
geometry=td.Box(size=(1, 1, 1), center=(-1, 0, 0)),
medium=td.PMC,
),
td.Structure(
geometry=td.Cylinder(radius=1.0, length=2.0, center=(1.0, 0.0, -1.0), axis=1),
medium=td.AnisotropicMedium(
Expand Down
4 changes: 4 additions & 0 deletions tidy3d/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@
from .components.medium import (
PEC,
PEC2D,
PMC,
AbstractMedium,
AnisotropicMedium,
CustomAnisotropicMedium,
Expand All @@ -269,6 +270,7 @@
PECMedium,
PerturbationMedium,
PerturbationPoleResidue,
PMCMedium,
PoleResidue,
Sellmeier,
SurfaceImpedanceFitterParam,
Expand Down Expand Up @@ -415,6 +417,7 @@ def set_logging_level(level: str) -> None:
"MU_0",
"PEC",
"PEC2D",
"PMC",
"PML",
"TFSF",
"Absorber",
Expand Down Expand Up @@ -610,6 +613,7 @@ def set_logging_level(level: str) -> None:
"PECConformal",
"PECMedium",
"PMCBoundary",
"PMCMedium",
"PMLParams",
"PMLTypes",
"ParameterPerturbation",
Expand Down
1 change: 1 addition & 0 deletions tidy3d/components/material/multi_physics.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ def __getattr__(self, name: str):
return None

DELEGATED_ATTRIBUTES = {
"is_pmc": self.optical,
"_eps_plot": self.optical,
"viz_spec": self.optical,
"eps_diagonal_numerical": self.optical,
Expand Down
84 changes: 73 additions & 11 deletions tidy3d/components/medium.py
Original file line number Diff line number Diff line change
Expand Up @@ -884,7 +884,7 @@ def _validate_modulation_spec(cls, val, values):
nonlinear_spec = values.get("nonlinear_spec")
if val is not None and nonlinear_spec is not None:
raise ValidationError(
f"For medium class {cls}, 'modulation_spec' of class {type(val)} and "
f"For medium class {cls.__name__}, 'modulation_spec' of class {type(val)} and "
f"'nonlinear_spec' of class {type(nonlinear_spec)} are "
"not simultaneously supported."
)
Expand Down Expand Up @@ -1350,6 +1350,11 @@ def is_pec(self):
"""Whether the medium is a PEC."""
return False

@cached_property
def is_pmc(self):
"""Whether the medium is a PMC."""
return False

def sel_inside(self, bounds: Bound) -> AbstractMedium:
"""Return a new medium that contains the minimal amount data necessary to cover
a spatial region defined by ``bounds``.
Expand Down Expand Up @@ -1740,7 +1745,7 @@ def _validate_modulation_spec(cls, val):
if val is not None:
raise ValidationError(
f"A 'modulation_spec' of class {type(val)} is not "
f"currently supported for medium class {cls}."
f"currently supported for medium class {cls.__name__}."
)
return val

Expand All @@ -1767,6 +1772,52 @@ def is_pec(self):
PEC = PECMedium(name="PEC")


# PMC keyword
class PMCMedium(AbstractMedium):
"""Perfect magnetic conductor class.

Note
----

To avoid confusion from duplicate PMCs, must import ``tidy3d.PMC`` instance directly.



"""

@pd.validator("modulation_spec", always=True)
def _validate_modulation_spec(cls, val):
"""Check compatibility with modulation_spec."""
if val is not None:
raise ValidationError(
f"A 'modulation_spec' of class {type(val)} is not "
f"currently supported for medium class {cls.__name__}."
)
return val

@ensure_freq_in_range
def eps_model(self, frequency: float) -> complex:
# permittivity of a PMC.
return 1.0 + 0j

@cached_property
def n_cfl(self):
"""This property computes the index of refraction related to CFL condition, so that
the FDTD with this medium is stable when the time step size that doesn't take
material factor into account is multiplied by ``n_cfl``.
"""
return 1.0

@cached_property
def is_pmc(self):
"""Whether the medium is a PMC."""
return True


# PEC builtin instance
PMC = PMCMedium(name="PMC")


class Medium(AbstractMedium):
"""Dispersionless medium. Mediums define the optical properties of the materials within the simulation.

Expand Down Expand Up @@ -5643,9 +5694,10 @@ def plot(
return ax


IsotropicUniformMediumType = Union[
IsotropicUniformMediumFor2DType = Union[
Medium, LossyMetalMedium, PoleResidue, Sellmeier, Lorentz, Debye, Drude, PECMedium
]
IsotropicUniformMediumType = Union[IsotropicUniformMediumFor2DType, PMCMedium]
IsotropicCustomMediumType = Union[
CustomPoleResidue,
CustomSellmeier,
Expand Down Expand Up @@ -5719,7 +5771,7 @@ def _validate_modulation_spec(cls, val):
if val is not None:
raise ValidationError(
f"A 'modulation_spec' of class {type(val)} is not "
f"currently supported for medium class {cls}. "
f"currently supported for medium class {cls.__name__}. "
"Please add modulation to each component."
)
return val
Expand Down Expand Up @@ -5852,10 +5904,19 @@ def is_pec(self):
"""Whether the medium is a PEC."""
return any(self.is_comp_pec(i) for i in range(3))

@cached_property
def is_pmc(self):
"""Whether the medium is a PMC."""
return any(self.is_comp_pmc(i) for i in range(3))

def is_comp_pec(self, comp: Axis):
"""Whether the medium is a PEC."""
return isinstance(self.components[["xx", "yy", "zz"][comp]], PECMedium)

def is_comp_pmc(self, comp: Axis):
"""Whether the medium is a PMC."""
return isinstance(self.components[["xx", "yy", "zz"][comp]], PMCMedium)

def sel_inside(self, bounds: Bound):
"""Return a new medium that contains the minimal amount data necessary to cover
a spatial region defined by ``bounds``.
Expand Down Expand Up @@ -5945,7 +6006,7 @@ def _validate_modulation_spec(cls, val):
if val is not None:
raise ValidationError(
f"A 'modulation_spec' of class {type(val)} is not "
f"currently supported for medium class {cls}."
f"currently supported for medium class {cls.__name__}."
)
return val

Expand Down Expand Up @@ -6970,6 +7031,7 @@ def perturbed_copy(
Medium,
AnisotropicMedium,
PECMedium,
PMCMedium,
PoleResidue,
Sellmeier,
Lorentz,
Expand Down Expand Up @@ -7004,7 +7066,7 @@ class Medium2D(AbstractMedium):

"""

ss: IsotropicUniformMediumType = pd.Field(
ss: IsotropicUniformMediumFor2DType = pd.Field(
...,
title="SS Component",
description="Medium describing the ss-component of the diagonal permittivity tensor. "
Expand All @@ -7015,7 +7077,7 @@ class Medium2D(AbstractMedium):
discriminator=TYPE_TAG_STR,
)

tt: IsotropicUniformMediumType = pd.Field(
tt: IsotropicUniformMediumFor2DType = pd.Field(
...,
title="TT Component",
description="Medium describing the tt-component of the diagonal permittivity tensor. "
Expand All @@ -7032,7 +7094,7 @@ def _validate_modulation_spec(cls, val):
if val is not None:
raise ValidationError(
f"A 'modulation_spec' of class {type(val)} is not "
f"currently supported for medium class {cls}."
f"currently supported for medium class {cls.__name__}."
)
return val

Expand All @@ -7049,7 +7111,7 @@ def _validate_inplane_pec(cls, val, values):

@classmethod
def _weighted_avg(
cls, meds: list[IsotropicUniformMediumType], weights: list[float]
cls, meds: list[IsotropicUniformMediumFor2DType], weights: list[float]
) -> Union[PoleResidue, PECMedium]:
"""Average ``meds`` with weights ``weights``."""
eps_inf = 1
Expand Down Expand Up @@ -7103,7 +7165,7 @@ def volumetric_equivalent(
The 3D material corresponding to this 2D material.
"""

def get_component(med: MediumType3D, comp: Axis) -> IsotropicUniformMediumType:
def get_component(med: MediumType3D, comp: Axis) -> IsotropicUniformMediumFor2DType:
"""Extract the ``comp`` component of ``med``."""
if isinstance(med, AnisotropicMedium):
dim = "xyz"[comp]
Expand Down Expand Up @@ -7365,7 +7427,7 @@ def sigma_model(self, freq: float) -> complex:
return np.mean([self.ss.sigma_model(freq), self.tt.sigma_model(freq)], axis=0)

@property
def elements(self) -> dict[str, IsotropicUniformMediumType]:
def elements(self) -> dict[str, IsotropicUniformMediumFor2DType]:
"""The diagonal elements of the 2D medium as a dictionary."""
return {"ss": self.ss, "tt": self.tt}

Expand Down
2 changes: 2 additions & 0 deletions tidy3d/components/mode/mode_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -1811,6 +1811,8 @@ def _contain_good_conductor(self) -> bool:
for medium in sim.scene.mediums:
if medium.is_pec:
return True
if medium.is_pmc:
return True
if apply_sibc and isinstance(medium, LossyMetalMedium):
return True
return False
Expand Down
12 changes: 12 additions & 0 deletions tidy3d/components/scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,9 +519,11 @@ def _get_structure_plot_params(

if isinstance(medium, MultiPhysicsMedium):
is_pec = medium.optical is not None and medium.optical.is_pec
is_pmc = medium.optical is not None and medium.optical.is_pmc
is_time_modulated = medium.optical is not None and medium.optical.is_time_modulated
else:
is_pec = medium.is_pec
is_pmc = medium.is_pmc
is_time_modulated = medium.is_time_modulated

if mat_index == 0 or medium == self.medium:
Expand All @@ -532,6 +534,11 @@ def _get_structure_plot_params(
plot_params = plot_params.copy(
update={"facecolor": "gold", "edgecolor": "k", "linewidth": 1}
)
elif is_pmc:
# perfect magnetic conductor
plot_params = plot_params.copy(
update={"facecolor": "purple", "edgecolor": "k", "linewidth": 1}
)
elif is_time_modulated:
# time modulated medium
plot_params = plot_params.copy(
Expand Down Expand Up @@ -1285,6 +1292,11 @@ def _get_structure_eps_plot_params(
plot_params = plot_params.copy(
update={"facecolor": "gold", "edgecolor": "k", "linewidth": 1}
)
elif medium.is_pmc:
# perfect magnetic conductor
plot_params = plot_params.copy(
update={"facecolor": "purple", "edgecolor": "k", "linewidth": 1}
)
elif isinstance(medium, Medium2D):
# 2d material
plot_params = plot_params.copy(update={"edgecolor": "k", "linewidth": 1})
Expand Down
Loading
Loading