diff --git a/tests/test_components/material/test_multi_physics.py b/tests/test_components/material/test_multi_physics.py new file mode 100644 index 0000000000..0641b00a70 --- /dev/null +++ b/tests/test_components/material/test_multi_physics.py @@ -0,0 +1,39 @@ +import copy + +import pytest +import tidy3d as td + + +@pytest.fixture +def dummy_optical(): + return td.Medium(permittivity=1.0) + + +def test_delegated_attributes_work(dummy_optical): + mp = td.MultiPhysicsMedium(optical=dummy_optical) + + # delegated names resolve + assert mp.is_pec is dummy_optical.is_pec + assert mp._eps_plot == dummy_optical._eps_plot + assert mp.viz_spec == dummy_optical.viz_spec + + # deepcopy still succeeds because __deepcopy__ is ignored + copy.deepcopy(mp) + + +def test_delegated_attribute_without_optical_raises(): + mp_no_opt = td.MultiPhysicsMedium(optical=None) + + with pytest.raises(AttributeError, match=r"optical medium is 'None'"): + _ = mp_no_opt.is_pec + + +def test_has_cached_props(dummy_optical): + mp = td.MultiPhysicsMedium(optical=dummy_optical) + mp._cached_properties + + +def test_unknown_attribute_error(dummy_optical): + mp = td.MultiPhysicsMedium(optical=dummy_optical) + with pytest.raises(AttributeError, match=r"Did you mean to access the attribute of one"): + _ = mp.not_a_real_attribute diff --git a/tidy3d/components/material/multi_physics.py b/tidy3d/components/material/multi_physics.py index 4359bfd3fa..ea60652536 100644 --- a/tidy3d/components/material/multi_physics.py +++ b/tidy3d/components/material/multi_physics.py @@ -128,6 +128,12 @@ def __getattr__(self, name: str): Extend that mapping as additional cross-medium shim behaviour becomes necessary. """ + # first check whether the attribute is already present + try: + return super().__getattr__(name) + except AttributeError: + pass + IGNORED_ATTRIBUTES = ["__deepcopy__"] if name in IGNORED_ATTRIBUTES: return None @@ -139,11 +145,18 @@ def __getattr__(self, name: str): } if name in DELEGATED_ATTRIBUTES: - return getattr(DELEGATED_ATTRIBUTES[name], name) - else: - raise ValueError( - f"MultiPhysicsMedium has no attribute called {name}. Did you mean to access the attribute of one of the optical, heat or charge media?" - ) + sub = DELEGATED_ATTRIBUTES[name] + if sub is None: + raise AttributeError( + f"Requested attribute {name!r}, but the optical medium is 'None' " + " on this 'MultiPhysicsMedium' instance." + ) + return getattr(sub, name) + + raise AttributeError( + f"MultiPhysicsMedium has no attribute called {name}. " + "Did you mean to access the attribute of one of the optical, heat or charge media?" + ) @property def heat_spec(self):