Skip to content

Commit bb076d1

Browse files
feat(tidy3d): Mutable attrs bypass _json_string cache, causing stale hashes and exports
- _json_string is cached, but refreshed when attrs refresh Jira: FXC-3374
1 parent 6f783bd commit bb076d1

File tree

3 files changed

+75
-12
lines changed

3 files changed

+75
-12
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010

1111
### Changed
1212
- Improved performance of antenna metrics calculation by utilizing cached wave amplitude calculations instead of recomputing wave amplitudes for each port excitation in the `TerminalComponentModelerData`.
13+
- Fixed: In `Tidy3dBaseModel` the hash (and cached `.json_string`) are now sensitive to changes in `.attrs`.
1314

1415
### Fixed
1516
- More robust `Sellmeier` and `Debye` material model, and prevent very large pole parameters in `PoleResidue` material model.

tests/test_components/test_base.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,18 @@ def test_scientific_notation(min_val, max_val, min_digits, expected):
260260
"""Test the _scientific_notation method with various inputs."""
261261
result = Tidy3dBaseModel._scientific_notation(min_val, max_val, min_digits=min_digits)
262262
assert result == expected
263+
264+
265+
def test_updated_hash_and_json_with_changed_attr():
266+
obj = td.Medium(attrs={"foo": "attr"})
267+
268+
old_hash = obj._hash_self()
269+
json_old = obj._json_string
270+
271+
obj.attrs["foo"] = "changed"
272+
273+
new_hash = obj._hash_self()
274+
json_new = obj._json_string
275+
276+
assert new_hash != old_hash
277+
assert json_old != json_new

tidy3d/components/base.py

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from autograd.builtins import dict as dict_ag
2323
from autograd.tracer import isbox
2424
from pydantic.v1.fields import ModelField
25+
from pydantic.v1.json import custom_pydantic_encoder
2526

2627
from tidy3d.exceptions import FileError
2728
from tidy3d.log import log
@@ -68,13 +69,47 @@ def cached_property(cached_property_getter):
6869
return property(cache(cached_property_getter))
6970

7071

72+
def cached_property_guarded(key_func):
73+
"""Like cached_property, but invalidates when the key_func(self) changes."""
74+
75+
def _decorator(getter):
76+
prop_name = getter.__name__
77+
78+
@wraps(getter)
79+
def _guarded(self):
80+
cache_store = self._cached_properties.get(prop_name)
81+
current_key = key_func(self)
82+
if cache_store is not None:
83+
cached_key, cached_value = cache_store
84+
if cached_key == current_key:
85+
return cached_value
86+
value = getter(self)
87+
self._cached_properties[prop_name] = (current_key, value)
88+
return value
89+
90+
return property(_guarded)
91+
92+
return _decorator
93+
94+
7195
def ndarray_encoder(val):
7296
"""How a ``np.ndarray`` gets handled before saving to json."""
7397
if np.any(np.iscomplex(val)):
7498
return {"real": val.real.tolist(), "imag": val.imag.tolist()}
7599
return val.real.tolist()
76100

77101

102+
def make_json_compatible(json_string: str) -> str:
103+
"""Makes the string compatible with json standards, notably for infinity."""
104+
105+
tmp_string = "<<TEMPORARY_INFINITY_STRING>>"
106+
json_string = json_string.replace("-Infinity", tmp_string)
107+
json_string = json_string.replace('""-Infinity""', tmp_string)
108+
json_string = json_string.replace("Infinity", '"Infinity"')
109+
json_string = json_string.replace('""Infinity""', '"Infinity"')
110+
return json_string.replace(tmp_string, '"-Infinity"')
111+
112+
78113
def _get_valid_extension(fname: str) -> str:
79114
"""Return the file extension from fname, validated to accepted ones."""
80115
valid_extensions = [".json", ".yaml", ".hdf5", ".h5", ".hdf5.gz"]
@@ -217,6 +252,24 @@ def _special_characters_not_in_name(cls, values):
217252
"by calling ``obj.json()``.",
218253
)
219254

255+
def _attrs_digest(self) -> str:
256+
"""Stable digest of `attrs` using the same JSON encoding rules as pydantic .json()."""
257+
encoders = getattr(self.__config__, "json_encoders", {}) or {}
258+
259+
def _default(o):
260+
return custom_pydantic_encoder(encoders, o)
261+
262+
json_str = json.dumps(
263+
self.attrs,
264+
default=_default,
265+
sort_keys=True,
266+
separators=(",", ":"),
267+
ensure_ascii=False,
268+
)
269+
json_str = make_json_compatible(json_str)
270+
271+
return hashlib.sha256(json_str.encode("utf-8")).hexdigest()
272+
220273
def copy(self, deep: bool = True, validate: bool = True, **kwargs) -> Tidy3dBaseModel:
221274
"""Copy a Tidy3dBaseModel. With ``deep=True`` and ``validate=True`` as default."""
222275
kwargs.update(deep=deep)
@@ -719,7 +772,11 @@ def from_hdf5(
719772
)
720773
return cls.parse_obj(model_dict, **parse_obj_kwargs)
721774

722-
def to_hdf5(self, fname: str, custom_encoders: Optional[list[Callable]] = None) -> None:
775+
def to_hdf5(
776+
self,
777+
fname: str,
778+
custom_encoders: Optional[list[Callable]] = None,
779+
) -> None:
723780
"""Exports :class:`Tidy3dBaseModel` instance to .hdf5 file.
724781
725782
Parameters
@@ -931,7 +988,7 @@ def check_equal(dict1: dict, dict2: dict) -> bool:
931988

932989
return check_equal(self.dict(), other.dict())
933990

934-
@cached_property
991+
@cached_property_guarded(lambda self: self._attrs_digest())
935992
def _json_string(self) -> str:
936993
"""Returns string representation of a :class:`Tidy3dBaseModel`.
937994
@@ -955,16 +1012,6 @@ def _json(self, indent=INDENT, exclude_unset=False, **kwargs) -> str:
9551012
Json-formatted string holding :class:`Tidy3dBaseModel` data.
9561013
"""
9571014

958-
def make_json_compatible(json_string: str) -> str:
959-
"""Makes the string compatible with json standards, notably for infinity."""
960-
961-
tmp_string = "<<TEMPORARY_INFINITY_STRING>>"
962-
json_string = json_string.replace("-Infinity", tmp_string)
963-
json_string = json_string.replace('""-Infinity""', tmp_string)
964-
json_string = json_string.replace("Infinity", '"Infinity"')
965-
json_string = json_string.replace('""Infinity""', '"Infinity"')
966-
return json_string.replace(tmp_string, '"-Infinity"')
967-
9681015
json_string = self.json(indent=indent, exclude_unset=exclude_unset, **kwargs)
9691016
json_string = make_json_compatible(json_string)
9701017
return json_string

0 commit comments

Comments
 (0)