From 13c9c41b7dac9476f2daf7198fec2eb0a7cd90d5 Mon Sep 17 00:00:00 2001 From: marcorudolphflex Date: Wed, 8 Oct 2025 13:16:34 +0200 Subject: [PATCH] fix(tidy3d): Mutable attrs bypass _json_string cache, causing stale... hashes and exports - _json_string is cached, but refreshed when attrs refresh - changed hashing method from sha256 to md5 Jira: FXC-3374 --- CHANGELOG.md | 2 + tests/test_components/test_base.py | 15 ++++++ tidy3d/components/base.py | 73 ++++++++++++++++++++++++------ 3 files changed, 77 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b86cb8520..9ca36109fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Improved performance of antenna metrics calculation by utilizing cached wave amplitude calculations instead of recomputing wave amplitudes for each port excitation in the `TerminalComponentModelerData`. +- Changed hashing method in `Tidy3dBaseModel` from sha256 to md5. ### Fixed - More robust `Sellmeier` and `Debye` material model, and prevent very large pole parameters in `PoleResidue` material model. - Bug in `WavePort` when more than one mode is requested in the `ModeSpec`. - Solver error for named 2D materials with inhomogeneous substrates. +- In `Tidy3dBaseModel` the hash (and cached `.json_string`) are now sensitive to changes in `.attrs`. ## [v2.10.0rc2] - 2025-10-01 diff --git a/tests/test_components/test_base.py b/tests/test_components/test_base.py index 1a82febfe3..4118359044 100644 --- a/tests/test_components/test_base.py +++ b/tests/test_components/test_base.py @@ -260,3 +260,18 @@ def test_scientific_notation(min_val, max_val, min_digits, expected): """Test the _scientific_notation method with various inputs.""" result = Tidy3dBaseModel._scientific_notation(min_val, max_val, min_digits=min_digits) assert result == expected + + +def test_updated_hash_and_json_with_changed_attr(): + obj = td.Medium(attrs={"foo": "attr"}) + + old_hash = obj._hash_self() + json_old = obj._json_string + + obj.attrs["foo"] = "changed" + + new_hash = obj._hash_self() + json_new = obj._json_string + + assert new_hash != old_hash + assert json_old != json_new diff --git a/tidy3d/components/base.py b/tidy3d/components/base.py index de0a1df789..2a51c3bd2e 100644 --- a/tidy3d/components/base.py +++ b/tidy3d/components/base.py @@ -22,6 +22,7 @@ from autograd.builtins import dict as dict_ag from autograd.tracer import isbox from pydantic.v1.fields import ModelField +from pydantic.v1.json import custom_pydantic_encoder from tidy3d.exceptions import FileError from tidy3d.log import log @@ -68,6 +69,29 @@ def cached_property(cached_property_getter): return property(cache(cached_property_getter)) +def cached_property_guarded(key_func): + """Like cached_property, but invalidates when the key_func(self) changes.""" + + def _decorator(getter): + prop_name = getter.__name__ + + @wraps(getter) + def _guarded(self): + cache_store = self._cached_properties.get(prop_name) + current_key = key_func(self) + if cache_store is not None: + cached_key, cached_value = cache_store + if cached_key == current_key: + return cached_value + value = getter(self) + self._cached_properties[prop_name] = (current_key, value) + return value + + return property(_guarded) + + return _decorator + + def ndarray_encoder(val): """How a ``np.ndarray`` gets handled before saving to json.""" if np.any(np.iscomplex(val)): @@ -75,6 +99,17 @@ def ndarray_encoder(val): return val.real.tolist() +def make_json_compatible(json_string: str) -> str: + """Makes the string compatible with json standards, notably for infinity.""" + + tmp_string = "<>" + json_string = json_string.replace("-Infinity", tmp_string) + json_string = json_string.replace('""-Infinity""', tmp_string) + json_string = json_string.replace("Infinity", '"Infinity"') + json_string = json_string.replace('""Infinity""', '"Infinity"') + return json_string.replace(tmp_string, '"-Infinity"') + + def _get_valid_extension(fname: str) -> str: """Return the file extension from fname, validated to accepted ones.""" valid_extensions = [".json", ".yaml", ".hdf5", ".h5", ".hdf5.gz"] @@ -139,7 +174,7 @@ def _hash_self(self) -> str: """Hash this component with ``hashlib`` in a way that is the same every session.""" bf = io.BytesIO() self.to_hdf5(bf) - return hashlib.sha256(bf.getvalue()).hexdigest() + return hashlib.md5(bf.getvalue()).hexdigest() def __init__(self, **kwargs): """Init method, includes post-init validators.""" @@ -217,6 +252,24 @@ def _special_characters_not_in_name(cls, values): "by calling ``obj.json()``.", ) + def _attrs_digest(self) -> str: + """Stable digest of `attrs` using the same JSON encoding rules as pydantic .json().""" + encoders = getattr(self.__config__, "json_encoders", {}) or {} + + def _default(o): + return custom_pydantic_encoder(encoders, o) + + json_str = json.dumps( + self.attrs, + default=_default, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ) + json_str = make_json_compatible(json_str) + + return hashlib.sha256(json_str.encode("utf-8")).hexdigest() + def copy(self, deep: bool = True, validate: bool = True, **kwargs) -> Tidy3dBaseModel: """Copy a Tidy3dBaseModel. With ``deep=True`` and ``validate=True`` as default.""" kwargs.update(deep=deep) @@ -719,7 +772,11 @@ def from_hdf5( ) return cls.parse_obj(model_dict, **parse_obj_kwargs) - def to_hdf5(self, fname: str, custom_encoders: Optional[list[Callable]] = None) -> None: + def to_hdf5( + self, + fname: str, + custom_encoders: Optional[list[Callable]] = None, + ) -> None: """Exports :class:`Tidy3dBaseModel` instance to .hdf5 file. Parameters @@ -931,7 +988,7 @@ def check_equal(dict1: dict, dict2: dict) -> bool: return check_equal(self.dict(), other.dict()) - @cached_property + @cached_property_guarded(lambda self: self._attrs_digest()) def _json_string(self) -> str: """Returns string representation of a :class:`Tidy3dBaseModel`. @@ -955,16 +1012,6 @@ def _json(self, indent=INDENT, exclude_unset=False, **kwargs) -> str: Json-formatted string holding :class:`Tidy3dBaseModel` data. """ - def make_json_compatible(json_string: str) -> str: - """Makes the string compatible with json standards, notably for infinity.""" - - tmp_string = "<>" - json_string = json_string.replace("-Infinity", tmp_string) - json_string = json_string.replace('""-Infinity""', tmp_string) - json_string = json_string.replace("Infinity", '"Infinity"') - json_string = json_string.replace('""Infinity""', '"Infinity"') - return json_string.replace(tmp_string, '"-Infinity"') - json_string = self.json(indent=indent, exclude_unset=exclude_unset, **kwargs) json_string = make_json_compatible(json_string) return json_string