Skip to content
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# [Unreleased](https://github.com/pybamm-team/PyBaMM/)

## Breaking changes

- `Symbol.children`, `Symbol.domain` (and `secondary_domain`, `tertiary_domain`, `quaternary_domain`) and `FunctionParameter.input_names` return immutable tuples that compare equal to lists, and `Symbol.domains` returns a read-only copy, as do `to_json()`, `get_children_domains()`, `read_domain_or_domains()` and `pybamm.EMPTY_DOMAINS`. Editing them raises `TypeError` instead of emitting `SymbolMutationDeprecationWarning`, and not even `list` or `dict` methods can change a symbol through them. This ends the deprecation of these edits early; before 26.9 they left the symbol's identity stale. The sequences are no longer `list` instances and slicing them gives a plain tuple; tuples are accepted wherever domains are given. Build a new symbol with `with_domains`, `create_copy(new_children=...)` or `pybamm.replace`. The deprecated setters (`name`, `domains`, `scale`, ...) are unchanged. ([#5825](https://github.com/pybamm-team/PyBaMM/pull/5825))

## Deprecated

- Setting `Array.entries_string` or `Interpolant.entries_string`, and calling `StateVector.set_evaluation_array`, are in-place symbol updates and now emit `SymbolMutationDeprecationWarning`. Construct a new symbol instead. ([#5825](https://github.com/pybamm-team/PyBaMM/pull/5825))

## Optimizations

- Symbol mutation is no longer guarded by intercepting every attribute write, and `children` and `domain` return the stored immutable sequences instead of building a view on each access. Writes to a symbol's slots after construction are instead rejected by a static check of PyBaMM's code, tests, examples and documentation. Under the test suite's mutation guard, constructing an expression is twice as fast and parameterising it is 25% faster, which recovers most of the `test_parameterise` benchmark regression from [#5779](https://github.com/pybamm-team/PyBaMM/pull/5779). ([#5825](https://github.com/pybamm-team/PyBaMM/pull/5825))

# [v26.9.0.0](https://github.com/pybamm-team/PyBaMM/tree/pybamm-v26.9.0.0) - 2026-09-28

## Breaking changes
Expand Down
18 changes: 13 additions & 5 deletions docs/source/api/expression_tree/tree_util.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@ Model parameter processing remains available through
Migrating in-place updates
--------------------------

Public symbol setters and domain/list edits remain available but emit
``SymbolMutationDeprecationWarning``. Prefer constructing replacements::
Public symbol setters remain available but emit
``SymbolMutationDeprecationWarning``. ``children``, ``domain`` and
``input_names`` return immutable tuples that compare equal to lists, and
``domains`` returns a read-only copy: editing them raises ``TypeError``, and not
even ``list`` or ``dict`` methods can change the symbol through them. Prefer
constructing replacements::

updated = variable.create_copy(scale=2, reference=0, bounds=(0, 1))
expression = pybamm.replace(expression, {variable: updated})
Expand All @@ -44,8 +48,7 @@ appropriate class. IDs update automatically; callers need not call ``set_id``.

Deprecated updates invalidate cached IDs and derived values across live symbols.
This deliberately slow compatibility path adds no per-symbol tracking to normal
replacement. Public domain and child-list views support deprecated edits while
internal storage remains shared. Mutating symbols already used as dictionary keys
replacement. Mutating symbols already used as dictionary keys
cannot repair those dictionaries: use a fresh replacement memo and rebuild any
parameter-processed or discretised model after legacy edits. Changes to a child's
domains do not propagate into constructor-derived parent domains; reconstruct
Expand All @@ -54,4 +57,9 @@ active because frozen ancestors cannot be discovered for cache invalidation;
use out-of-place replacement or unfreeze the collector first.

The test suite forbids deprecated mutation even with warnings ignored. Only isolated compatibility subprocesses exercise the legacy path;
notebook kernels inherit the same guard as other tests.
notebook kernels inherit the same guard as other tests. The guard
(``PYBAMM_TEST_FORBID_SYMBOL_MUTATION=1``) makes every deprecated public update
raise. A symbol's private slots, such as ``symbol._name``, are not part of the
API and writing them is unsupported: rather than intercept every attribute write
at runtime, the test suite checks PyBaMM's own code, tests, examples and
documentation for such writes statically.
Original file line number Diff line number Diff line change
Expand Up @@ -934,7 +934,7 @@ def _discretises_own_children(cls, symbol):
def _discretise_node(self, symbol, new_leaves):
"""Discretise one node given its discretised children; the callback for
:func:`pybamm.tree_map`."""
if symbol._domains["primary"] != [] and self.bcs:
if symbol._domains["primary"] and self.bcs:
# If boundary conditions are provided, need to check for BCs on tabs
key_id = next(iter(self.bcs.keys()))
if LEGACY_TAB_SIDES & set(self.bcs[key_id].keys()):
Expand All @@ -951,7 +951,7 @@ def _discretise_node(self, symbol, new_leaves):
def _spatial_method_of(self, symbol):
"""The spatial method of a symbol's primary domain (None if it has none)."""
domain = symbol._domains["primary"]
if domain == []:
if not domain:
return None
return self.spatial_methods[domain[0]]

Expand Down
34 changes: 19 additions & 15 deletions packages/pybamm/src/pybamm/expression_tree/array.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def __init__(
name = f"Array of shape {entries.shape!s}"
self._entries = entries.astype(float)
# Use known entries string to avoid re-hashing, where possible
self.entries_string = entries_string
self._entries_string = self._make_entries_string(entries_string)
super().__init__(
name, domain=domain, auxiliary_domains=auxiliary_domains, domains=domains
)
Expand Down Expand Up @@ -109,22 +109,26 @@ def entries_string(self):

@entries_string.setter
def entries_string(self, value: tuple | None):
pybamm.expression_tree.symbol._warn_mutation(
"entries_string", "Construct a new Array with the desired entries."
)
object.__setattr__(self, "_entries_string", self._make_entries_string(value))

def _make_entries_string(self, value: tuple | None) -> tuple:
"""The hashable form of the entries: ``value`` if given, else computed."""
# We must include the entries in the hash, since different arrays can be
# indistinguishable by class, name and domain alone
# Slightly different syntax for sparse and non-sparse matrices
if value is not None:
self._entries_string = value
else:
entries = self._entries
if issparse(entries):
dct = entries.__dict__
entries_string = ["shape", str(dct["_shape"])]
for key in ["data", "indices", "indptr"]:
entries_string += [key, dct[key].tobytes()]
self._entries_string = tuple(entries_string)
# self._entries_string = str(entries.__dict__)
else:
self._entries_string = (entries.tobytes(),)
return value
entries = self._entries
# Slightly different syntax for sparse and non-sparse matrices
if issparse(entries):
dct = entries.__dict__
entries_string = ["shape", str(dct["_shape"])]
for key in ["data", "indices", "indptr"]:
entries_string += [key, dct[key].tobytes()]
return tuple(entries_string)
return (entries.tobytes(),)

def _to_casadi(self, t, y, y_dot, inputs, casadi_symbols):
"""See :meth:`pybamm.Symbol._to_casadi()`."""
Expand Down Expand Up @@ -185,7 +189,7 @@ def to_json(self):

json_dict = {
"name": self.name,
"domains": self._domains,
"domains": self.domains,
"entries": matrix,
}

Expand Down
2 changes: 1 addition & 1 deletion packages/pybamm/src/pybamm/expression_tree/averages.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ def __init__(self, child: pybamm.Symbol, f_a_dist) -> None:
def to_json(self):
return {
"name": self.name,
"domains": self._domains,
"domains": self.domains,
"children": [self._children[0], self.f_a_dist],
}

Expand Down
23 changes: 13 additions & 10 deletions packages/pybamm/src/pybamm/expression_tree/binary_operators.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ def _preprocess_binary(

# Do some broadcasting in special cases, to avoid having to do this manually
if (
left._domains["primary"] != []
and right._domains["primary"] != []
left._domains["primary"]
and right._domains["primary"]
and left._domains["primary"] != right._domains["primary"]
):
if left._domains["primary"] == right._domains["secondary"]:
Expand Down Expand Up @@ -92,7 +92,7 @@ def __init__(
) -> None:
left, right = _preprocess_binary(left_child, right_child)

domains = self.get_children_domains([left, right])
domains = self._combine_children_domains([left, right])
super().__init__(name, children=[left, right], domains=domains)

@property
Expand All @@ -106,7 +106,9 @@ def left(self, value: ChildSymbol) -> None:
)
children = list(self._children)
children[0] = pybamm.convert_to_symbol(value)
object.__setattr__(self, "_children", children)
object.__setattr__(
self, "_children", pybamm.expression_tree.symbol.SymbolChildren(children)
)

@property
def right(self) -> pybamm.Symbol:
Expand All @@ -119,7 +121,9 @@ def right(self, value: ChildSymbol) -> None:
)
children = list(self._children)
children[1] = pybamm.convert_to_symbol(value)
object.__setattr__(self, "_children", children)
object.__setattr__(
self, "_children", pybamm.expression_tree.symbol.SymbolChildren(children)
)

@classmethod
def _from_json(cls, snippet: dict):
Expand Down Expand Up @@ -264,7 +268,7 @@ def to_json(self):
Method to serialise a BinaryOperator object into JSON.
"""

json_dict = {"name": self.name, "domains": self._domains}
json_dict = {"name": self.name, "domains": self.domains}

return json_dict

Expand Down Expand Up @@ -1147,10 +1151,9 @@ def unpack_broadcast_recursive(symbol: pybamm.Symbol) -> pybamm.Symbol:
return out
return symbol

# No need to broadcast if the other symbol already has the shape that is being
# broadcasted to
# Do this recursively
if left._domains == right._domains:
# a broadcast to the other side's shape is redundant; interned domains are equal
# only if identical
if left._domains is right._domains:
if isinstance(left, pybamm.Broadcast) and left.broadcasts_to_nodes:
left = unpack_broadcast_recursive(left)
elif isinstance(right, pybamm.Broadcast) and right.broadcasts_to_nodes:
Expand Down
27 changes: 14 additions & 13 deletions packages/pybamm/src/pybamm/expression_tree/broadcasts.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from scipy.sparse import csr_matrix

import pybamm
from pybamm.expression_tree.symbol import _domain_list
from pybamm.type_definitions import (
AuxiliaryDomainType,
DomainsType,
Expand Down Expand Up @@ -117,8 +118,7 @@ def __init__(
child = cast(pybamm.Symbol, child)

# Convert domain to list if it's a string
if isinstance(broadcast_domain, str):
broadcast_domain = [broadcast_domain]
broadcast_domain = _domain_list(broadcast_domain)
# perform some basic checks and set attributes
domains = self.check_and_set_domains(child, broadcast_domain)
self.broadcast_domain = broadcast_domain
Expand Down Expand Up @@ -192,7 +192,7 @@ def _evaluate_for_shape(self):
def to_json(self):
return {
"name": self.name,
"domains": self._domains,
"domains": self.domains,
"broadcast_domain": self.broadcast_domain,
}

Expand Down Expand Up @@ -260,8 +260,7 @@ def __init__(
name: str | None = None,
):
# Convert domain to list if it's a string
if isinstance(broadcast_domain, str):
broadcast_domain = [broadcast_domain]
broadcast_domain = _domain_list(broadcast_domain)
# perform some basic checks and set attributes
domains = self.check_and_set_domains(child, broadcast_domain)
self.broadcast_domain = broadcast_domain
Expand Down Expand Up @@ -342,7 +341,7 @@ def _evaluate_for_shape(self):
def to_json(self):
return {
"name": self.name,
"domains": self._domains,
"domains": self.domains,
"broadcast_domain": self.broadcast_domain,
}

Expand Down Expand Up @@ -410,8 +409,7 @@ def __init__(
name: str | None = None,
):
# Convert domain to list if it's a string
if isinstance(broadcast_domain, str):
broadcast_domain = [broadcast_domain]
broadcast_domain = _domain_list(broadcast_domain)
# perform some basic checks and set attributes
domains = self.check_and_set_domains(child, broadcast_domain)
self.broadcast_domain = broadcast_domain
Expand Down Expand Up @@ -479,7 +477,7 @@ def _evaluate_for_shape(self):
def to_json(self):
return {
"name": self.name,
"domains": self._domains,
"domains": self.domains,
"broadcast_domain": self.broadcast_domain,
}

Expand Down Expand Up @@ -541,9 +539,12 @@ def __init__(

if isinstance(auxiliary_domains, str):
auxiliary_domains = {"secondary": auxiliary_domains}
broadcast_domains = self.read_domain_or_domains(
broadcast_domain, auxiliary_domains, broadcast_domains
)
broadcast_domains = {
level: _domain_list(names)
for level, names in self._read_domain_or_domains(
broadcast_domain, auxiliary_domains, broadcast_domains
).items()
}
# perform some basic checks and set attributes
domains = self.check_and_set_domains(child, broadcast_domains)
self.broadcast_domain = broadcast_domains["primary"]
Expand All @@ -569,7 +570,7 @@ def _unary_new_copy(self, child, perform_simplifications=True):
return self.__class__(child, broadcast_domains=self._domains)

def to_json(self):
return {"name": self.name, "domains": self._domains}
return {"name": self.name, "domains": self.domains}

@classmethod
def _from_json(cls, snippet):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def __init__(
if name is None:
name = "concatenation"
if check_domain:
domains = self.get_children_domains(children)
domains = self._combine_children_domains(children)
else:
domains = {"primary": []}
self.concatenation_function = concat_fun
Expand Down Expand Up @@ -96,7 +96,7 @@ def _diff(self, variable: pybamm.Symbol):

return diff

def get_children_domains(self, children: Sequence[pybamm.Symbol]):
def _combine_children_domains(self, children: Sequence[pybamm.Symbol]):
# combine domains from children
domain: list = []
for child in children:
Expand Down Expand Up @@ -495,7 +495,7 @@ def unpack_defaultDict(slices):

json_dict = {
"name": self.name,
"domains": self._domains,
"domains": self.domains,
"slices": unpack_defaultDict(self._slices),
"size": self._size,
"children_slices": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def __init__(self, selector: pybamm.Symbol, *branches: pybamm.Symbol):
if selector.shape_for_testing != ():
raise ValueError("Conditional selector must evaluate to a scalar")

domains = self.get_children_domains(branches)
domains = self._combine_children_domains(branches)
first_shape = branches[0].shape_for_testing
first_size = branches[0].size_for_testing
returns_scalar = first_size == 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def __init__(
def to_json(self):
return {
"name": self.name,
"domains": self._domains,
"domains": self.domains,
"time_points": self.x[0].tolist(),
"data": self.y.tolist(),
}
Expand Down
2 changes: 1 addition & 1 deletion packages/pybamm/src/pybamm/expression_tree/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def __init__(
name = f"function ({function.__name__})"
except AttributeError:
name = f"function ({function.__class__})"
domains = self.get_children_domains(children)
domains = self._combine_children_domains(children)

self.function = function
self.differentiated_function = differentiated_function
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ def create_copy(
def to_json(self):
return {
"name": self.name,
"domains": self._domains,
"domains": self.domains,
"coord_sys": self.coord_sys,
"direction": getattr(self, "direction", None),
}
Expand Down
23 changes: 14 additions & 9 deletions packages/pybamm/src/pybamm/expression_tree/interpolant.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def __init__(
name = "interpolating_function"
self.x = x
self.y = y
self.entries_string = entries_string
self._entries_string = self._make_entries_string(entries_string)

# Differentiate the interpolating function if necessary
self._num_derivatives = _num_derivatives
Expand Down Expand Up @@ -199,16 +199,21 @@ def entries_string(self):

@entries_string.setter
def entries_string(self, value):
# We must include the entries in the hash, since different arrays can be
pybamm.expression_tree.symbol._warn_mutation(
"entries_string", "Construct a new Interpolant with the desired data."
)
object.__setattr__(self, "_entries_string", self._make_entries_string(value))

def _make_entries_string(self, value: str | None) -> str:
"""The hashable form of the data: ``value`` if given, else computed."""
# We must include the data in the hash, since different interpolants can be
# indistinguishable by class, name and domain alone
# Slightly different syntax for sparse and non-sparse matrices
if value is not None:
self._entries_string = value
else:
self._entries_string = ""
for i, x in enumerate(self.x):
self._entries_string += "x" + str(i) + "_" + str(x.tobytes())
self._entries_string += "y_" + str(self.y.tobytes())
return value
entries_string = ""
for i, x in enumerate(self.x):
entries_string += "x" + str(i) + "_" + str(x.tobytes())
return entries_string + "y_" + str(self.y.tobytes())

def create_copy(self, new_children=None, perform_simplifications=True):
"""See :meth:`pybamm.Symbol.new_copy()`."""
Expand Down
Loading
Loading