diff --git a/CHANGELOG.md b/CHANGELOG.md index e1c098eed1..c845e33e3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Features - Added unstructured mesh support (`UnstructuredSubMesh`, generators, and interface coupling) for arbitrary 2D/3D domains. Hexahedra must have planar faces (warped hexes raise a `GeometryError`), and `UserSuppliedUnstructuredMesh` accepts tetrahedral, triangular, and quadrilateral cells only. ([#5687](https://github.com/pybamm-team/PyBaMM/pull/5687)) +- Generalised `VectorField` to N components and added `Component`/`Norm` operators for multi-dimensional vector fields. ([#5686](https://github.com/pybamm-team/PyBaMM/pull/5686)) ## Bug fixes diff --git a/docs/source/api/expression_tree/unary_operator.rst b/docs/source/api/expression_tree/unary_operator.rst index e6a3cbe554..38dc345bdd 100644 --- a/docs/source/api/expression_tree/unary_operator.rst +++ b/docs/source/api/expression_tree/unary_operator.rst @@ -73,6 +73,12 @@ Unary Operators .. autoclass:: pybamm.Downwind :members: +.. autoclass:: pybamm.Component + :members: + +.. autoclass:: pybamm.Norm + :members: + .. autofunction:: pybamm.grad .. autofunction:: pybamm.div diff --git a/packages/pybamm/src/pybamm/discretisations/discretisation.py b/packages/pybamm/src/pybamm/discretisations/discretisation.py index 779b64c2a2..cdb404505d 100644 --- a/packages/pybamm/src/pybamm/discretisations/discretisation.py +++ b/packages/pybamm/src/pybamm/discretisations/discretisation.py @@ -882,7 +882,11 @@ def process_symbol(self, symbol): # Assign mesh as an attribute to the processed variable if symbol.domain != []: - discretised_symbol.mesh = self.mesh[symbol.domain] + mesh_for_symbol = self.mesh[symbol.domain] + discretised_symbol.mesh = mesh_for_symbol + if isinstance(discretised_symbol, pybamm.VectorField): + for comp in discretised_symbol.components: + comp.mesh = mesh_for_symbol else: discretised_symbol.mesh = None @@ -900,6 +904,38 @@ def process_symbol(self, symbol): return discretised_symbol + def _process_vector_field_binary(self, symbol, disc_left, disc_right): + """Broadcast a scalar side, then apply ``symbol`` component-wise.""" + left_is_vf = isinstance(disc_left, pybamm.VectorField) + right_is_vf = isinstance(disc_right, pybamm.VectorField) + if left_is_vf and right_is_vf: + if disc_left.n_components != disc_right.n_components: + raise pybamm.DiscretisationError( + f"Cannot combine VectorFields with {disc_left.n_components} and " + f"{disc_right.n_components} components" + ) + n = disc_left.n_components + elif left_is_vf: + n = disc_left.n_components + disc_right = pybamm.VectorField(*[disc_right] * n) + else: + n = disc_right.n_components + disc_left = pybamm.VectorField(*[disc_left] * n) + new_comps = [ + pybamm.simplify_if_constant( + symbol.create_copy( + new_children=[disc_left.components[k], disc_right.components[k]] + ) + ) + for k in range(n) + ] + result = pybamm.VectorField(*new_comps) + if disc_left._disc_state_vector is not None: + result._disc_state_vector = disc_left._disc_state_vector + else: + result._disc_state_vector = disc_right._disc_state_vector + return result + def _process_symbol(self, symbol): """See :meth:`Discretisation.process_symbol()`.""" @@ -934,23 +970,9 @@ def _process_symbol(self, symbol): if isinstance(disc_left, pybamm.VectorField) or isinstance( disc_right, pybamm.VectorField ): - if not isinstance(disc_right, pybamm.VectorField): - disc_right = pybamm.VectorField(disc_right, disc_right) - if not isinstance(disc_left, pybamm.VectorField): - disc_left = pybamm.VectorField(disc_left, disc_left) - else: # both are vector fields already - pass - disc_lr = pybamm.simplify_if_constant( - symbol.create_copy( - new_children=[disc_left.lr_field, disc_right.lr_field] - ) + return self._process_vector_field_binary( + symbol, disc_left, disc_right ) - disc_tb = pybamm.simplify_if_constant( - symbol.create_copy( - new_children=[disc_left.tb_field, disc_right.tb_field] - ) - ) - return pybamm.VectorField(disc_lr, disc_tb) return pybamm.simplify_if_constant( symbol.create_copy(new_children=[disc_left, disc_right]) @@ -1092,6 +1114,18 @@ def _process_symbol(self, symbol): elif isinstance(symbol, pybamm.NotConstant): # After discretisation, we can make the symbol constant return disc_child + elif isinstance(symbol, pybamm.Component): + if not isinstance(disc_child, pybamm.VectorField): + raise pybamm.DiscretisationError( + "Component can only be applied to a VectorField" + ) + return disc_child.components[symbol.index] + elif isinstance(symbol, pybamm.Norm): + if not isinstance(disc_child, pybamm.VectorField): + raise pybamm.DiscretisationError( + "Norm can only be applied to a VectorField" + ) + return sum(c**2 for c in disc_child.components) ** 0.5 elif isinstance(symbol, pybamm.Magnitude): if not isinstance(disc_child, pybamm.VectorField): raise ValueError("Magnitude can only be applied to a vector field") @@ -1104,10 +1138,13 @@ def _process_symbol(self, symbol): raise ValueError("Invalid direction") else: if isinstance(disc_child, pybamm.VectorField): - return pybamm.VectorField( - symbol.create_copy(new_children=[disc_child.lr_field]), - symbol.create_copy(new_children=[disc_child.tb_field]), - ) + new_comps = [ + symbol.create_copy(new_children=[c]) + for c in disc_child.components + ] + result = pybamm.VectorField(*new_comps) + result._disc_state_vector = disc_child._disc_state_vector + return result else: return symbol.create_copy(new_children=[disc_child]) @@ -1181,10 +1218,8 @@ def _process_symbol(self, symbol): ) elif isinstance(symbol, pybamm.VectorField): - # VectorField is a subclass of TensorField, handle it first for specificity - left_symbol = self.process_symbol(symbol.lr_field) - right_symbol = self.process_symbol(symbol.tb_field) - return symbol.create_copy(new_children=[left_symbol, right_symbol]) + processed = [self.process_symbol(c) for c in symbol.components] + return symbol.create_copy(new_children=processed) elif isinstance(symbol, pybamm.TensorField): # General TensorField handling (rank-2 tensors) diff --git a/packages/pybamm/src/pybamm/expression_tree/unary_operators.py b/packages/pybamm/src/pybamm/expression_tree/unary_operators.py index 5e509d0684..b6a50821ba 100644 --- a/packages/pybamm/src/pybamm/expression_tree/unary_operators.py +++ b/packages/pybamm/src/pybamm/expression_tree/unary_operators.py @@ -1509,6 +1509,54 @@ def _unary_new_copy(self, child, perform_simplifications=True): return self.__class__(child, self.direction) +class Component(UnaryOperator): + """ + Extract component *index* from a VectorField. + + Parameters + ---------- + child : :class:`pybamm.Symbol` + A VectorField symbol. + index : int + Zero-based component index. + """ + + def __init__(self, child, index): + super().__init__(f"component({index})", child) + self.index = index + + def to_json(self): + return { + "name": self.name, + "domains": self.domains, + "index": self.index, + } + + @classmethod + def _from_json(cls, snippet): + return cls(snippet["children"][0], snippet["index"]) + + def _unary_new_copy(self, child, perform_simplifications=True): + return self.__class__(child, self.index) + + +class Norm(UnaryOperator): + """ + Euclidean norm of a VectorField: ``sqrt(sum(comp_i ** 2))``. + + Parameters + ---------- + child : :class:`pybamm.Symbol` + A VectorField symbol. + """ + + def __init__(self, child): + super().__init__("norm", child) + + def _unary_new_copy(self, child, perform_simplifications=True): + return self.__class__(child) + + class Upwind(UpwindDownwind): """ Upwinding operator. To be used if flow velocity is positive (left to right). diff --git a/packages/pybamm/src/pybamm/expression_tree/vector_field.py b/packages/pybamm/src/pybamm/expression_tree/vector_field.py index 031674d99e..79eddb9ccf 100644 --- a/packages/pybamm/src/pybamm/expression_tree/vector_field.py +++ b/packages/pybamm/src/pybamm/expression_tree/vector_field.py @@ -1,9 +1,11 @@ """ -VectorField class - a rank-1 tensor field for 2D simulations. +VectorField class - a rank-1 tensor field with N components. """ from __future__ import annotations +import casadi + import pybamm from pybamm.expression_tree.tensor_field import TensorField @@ -13,22 +15,31 @@ class VectorField(TensorField): A node in the expression tree representing a vector field. VectorField is a convenience subclass of TensorField for rank-1 tensors - with two components (lr and tb directions in 2D). + with N >= 2 components. Components are stored by integer index; the + properties ``lr_field`` and ``tb_field`` are aliases for ``[0]`` and ``[1]``. Parameters ---------- - lr_field : pybamm.Symbol - The left-right (x) component of the vector field. - tb_field : pybamm.Symbol - The top-bottom (y) component of the vector field. + *components : pybamm.Symbol + Two or more component symbols, all sharing the same domain. """ - def __init__(self, lr_field, tb_field): - if lr_field.domain != tb_field.domain: - raise ValueError("lr_field and tb_field must have the same domain") - # Initialize as a rank-1 TensorField with two components - super().__init__([lr_field, tb_field], domain=lr_field.domain) - # Override the name to maintain backward compatibility + # Set by discretisation for unstructured FV edge-averaging; None until then. + _disc_state_vector = None + + def __init__(self, *components): + if len(components) < 2: + raise ValueError( + f"VectorField requires at least 2 components, got {len(components)}" + ) + ref_domain = components[0].domain + for i, c in enumerate(components[1:], start=1): + if c.domain != ref_domain: + raise ValueError( + f"All components must have the same domain: " + f"component {i} has {c.domain}, expected {ref_domain}" + ) + super().__init__(list(components), domain=ref_domain) self.name = "vector_field" @classmethod @@ -36,44 +47,48 @@ def _from_json(cls, snippet): # Two positional args, not a single list -- override TensorField._from_json. return cls(snippet["children"][0], snippet["children"][1]) + @property + def n_components(self): + """Number of vector components.""" + return len(self.components) + + # ---- aliases for structured-grid directions ---- + @property def lr_field(self): - """The left-right (x) component of the vector field.""" - return self._components[0] + """Component 0 (left-right / x).""" + return self.components[0] @property def tb_field(self): - """The top-bottom (y) component of the vector field.""" - return self._components[1] + """Component 1 (top-bottom / y).""" + return self.components[1] def create_copy( self, new_children: list[pybamm.Symbol] | None = None, perform_simplifications: bool = True, ): - """Create a copy of this vector field with optional new children.""" if new_children is None: new_children = [ - self.lr_field.create_copy( - perform_simplifications=perform_simplifications - ), - self.tb_field.create_copy( - perform_simplifications=perform_simplifications - ), + c.create_copy(perform_simplifications=perform_simplifications) + for c in self.components ] return VectorField(*new_children) + def _to_casadi(self, t, y, y_dot, inputs, casadi_symbols): + """See :meth:`pybamm.Symbol._to_casadi()`.""" + return casadi.vertcat( + *self._children_to_casadi(t, y, y_dot, inputs, casadi_symbols) + ) + def evaluates_on_edges(self, dimension: str) -> bool: - """Check if components evaluate on edges. - - Overrides TensorField to provide more specific error message. - """ - left_evaluates_on_edges = self.lr_field.evaluates_on_edges(dimension) - right_evaluates_on_edges = self.tb_field.evaluates_on_edges(dimension) - if left_evaluates_on_edges == right_evaluates_on_edges: - return left_evaluates_on_edges - else: - raise ValueError( - "lr_field and tb_field must either both evaluate on edges " - "or both not evaluate on edges" - ) + statuses = [c.evaluates_on_edges(dimension) for c in self.components] + if all(statuses): + return True + if not any(statuses): + return False + raise ValueError( + "All VectorField components must either all evaluate on edges " + "or none evaluate on edges" + ) diff --git a/packages/pybamm/src/pybamm/solvers/processed_variable.py b/packages/pybamm/src/pybamm/solvers/processed_variable.py index 911c01712d..263becd5ed 100644 --- a/packages/pybamm/src/pybamm/solvers/processed_variable.py +++ b/packages/pybamm/src/pybamm/solvers/processed_variable.py @@ -1387,6 +1387,12 @@ def process_variable(name: str, base_variables, *args, **kwargs): return ProcessedVariable3DSciKitFEM(name, base_variables, *args, **kwargs) if mesh and hasattr(mesh, "edges_lr") and hasattr(mesh, "edges_tb"): + if isinstance(base_variables[0], pybamm.VectorField): + raise NotImplementedError( + "Reading VectorField variables from a Solution is not supported " + "on structured 2D finite-volume meshes. Use pybamm.Component to " + "extract a scalar component first." + ) return ProcessedVariable2DFVM(name, base_variables, *args, **kwargs) # check variable shape diff --git a/packages/pybamm/src/pybamm/solvers/solution.py b/packages/pybamm/src/pybamm/solvers/solution.py index a89af76cb2..b67ce33c7e 100644 --- a/packages/pybamm/src/pybamm/solvers/solution.py +++ b/packages/pybamm/src/pybamm/solvers/solution.py @@ -748,16 +748,30 @@ def _update_variable(self, name: str): "solve. Please re-run the solve with `output_variables` set to " "include this variable." ) - var_casadi, var_pybamm, time_integral = self._update_model_variable( - model, - _var_pybamm, - inputs=inputs, - ys_shape=ys.shape, - time_integral=time_integral, - cache_key=name, - ) - vars_pybamm[i] = var_pybamm - vars_casadi[i] = var_casadi + if isinstance(_var_pybamm, pybamm.VectorField): + comp_casadi = [] + for k, comp in enumerate(_var_pybamm.components): + cc, _, _ = self._update_model_variable( + model, + comp, + inputs=inputs, + ys_shape=ys.shape, + time_integral=None, + cache_key=f"{name}[{k}]", + ) + comp_casadi.append(cc) + vars_casadi[i] = comp_casadi + else: + var_casadi, var_pybamm, time_integral = self._update_model_variable( + model, + _var_pybamm, + inputs=inputs, + ys_shape=ys.shape, + time_integral=time_integral, + cache_key=name, + ) + vars_pybamm[i] = var_pybamm + vars_casadi[i] = var_casadi var = pybamm.process_variable( name, vars_pybamm, vars_casadi, self, time_integral=time_integral ) diff --git a/packages/pybamm/tests/strategies/symbols.py b/packages/pybamm/tests/strategies/symbols.py index 270519e7c1..4ad7a89627 100644 --- a/packages/pybamm/tests/strategies/symbols.py +++ b/packages/pybamm/tests/strategies/symbols.py @@ -670,6 +670,17 @@ def _magnitude_branch( ) +def _component_branch( + _child_strategy: st.SearchStrategy[pybamm.Symbol], +) -> st.SearchStrategy[pybamm.Component]: + """Component(child, index) — domain-bearing child, zero-based component index.""" + return st.builds( + pybamm.Component, + _any_domain_leaves(), + st.integers(min_value=0, max_value=2), + ) + + def _discrete_time_sum_branch( _child_strategy: st.SearchStrategy[pybamm.Symbol], ) -> st.SearchStrategy[pybamm.DiscreteTimeSum]: @@ -981,6 +992,9 @@ def _vector_branch( pybamm.UpwindDownwind2D: _upwind_downwind_2d_branch, pybamm.NodeToEdge2D: _node_to_edge_2d_branch, pybamm.Magnitude: _magnitude_branch, + pybamm.Component: _component_branch, + # Norm: (self, child) only — round-trips via the generic unary hook. + pybamm.Norm: lambda _children: _any_domain_leaves().map(pybamm.Norm), pybamm.DiscreteTimeData: _discrete_time_data_branch, pybamm.DiscreteTimeSum: _discrete_time_sum_branch, pybamm.SizeAverage: _size_average_branch, diff --git a/packages/pybamm/tests/unit/test_expression_tree/test_vector_field_and_magnitude.py b/packages/pybamm/tests/unit/test_expression_tree/test_vector_field_and_magnitude.py index f1f798775a..c8f07bb9a5 100644 --- a/packages/pybamm/tests/unit/test_expression_tree/test_vector_field_and_magnitude.py +++ b/packages/pybamm/tests/unit/test_expression_tree/test_vector_field_and_magnitude.py @@ -64,3 +64,53 @@ def test_vector_field_and_magnitude(self, mesh_2d): with pytest.raises(ValueError, match=r"Invalid direction"): disc.process_symbol(pybamm.Magnitude(vector_field, "asdf")) + + def test_component_and_norm_discretisation(self, mesh_2d): + spatial_methods = {"macroscale": pybamm.FiniteVolume2D()} + disc = pybamm.Discretisation(mesh_2d, spatial_methods) + vector_field = pybamm.VectorField(pybamm.Scalar(3), pybamm.Scalar(4)) + + comp_0 = disc.process_symbol(pybamm.Component(vector_field, 0)) + comp_1 = disc.process_symbol(pybamm.Component(vector_field, 1)) + assert comp_0.evaluate() == 3 + assert comp_1.evaluate() == 4 + + norm = disc.process_symbol(pybamm.Norm(vector_field)) + assert norm.evaluate() == pytest.approx(5.0) + + with pytest.raises( + pybamm.DiscretisationError, match=r"Component can only be applied" + ): + disc.process_symbol(pybamm.Component(pybamm.Scalar(1), 0)) + + with pytest.raises( + pybamm.DiscretisationError, match=r"Norm can only be applied" + ): + disc.process_symbol(pybamm.Norm(pybamm.Scalar(1))) + + def test_mismatched_vector_field_components(self, mesh_2d): + spatial_methods = {"macroscale": pybamm.FiniteVolume2D()} + disc = pybamm.Discretisation(mesh_2d, spatial_methods) + vf2 = pybamm.VectorField(pybamm.Scalar(1), pybamm.Scalar(2)) + vf3 = pybamm.VectorField(pybamm.Scalar(1), pybamm.Scalar(2), pybamm.Scalar(3)) + with pytest.raises( + pybamm.DiscretisationError, match=r"Cannot combine VectorFields" + ): + disc.process_symbol(vf2 + vf3) + + def test_disc_state_vector_propagation(self, mesh_2d): + # binary and unary operators on a discretised VectorField must carry + # the _disc_state_vector attribute over to the result + spatial_methods = {"macroscale": pybamm.FiniteVolume2D()} + disc = pybamm.Discretisation(mesh_2d, spatial_methods) + vector_field = pybamm.VectorField(pybamm.Scalar(1), pybamm.Scalar(2)) + disc_vf = disc.process_symbol(vector_field) + marker = pybamm.StateVector(slice(0, 1)) + disc_vf._disc_state_vector = marker + + one = pybamm.Constant(1, "one") + disc_sum = disc.process_symbol(vector_field + one) + assert disc_sum._disc_state_vector is marker + + disc_neg = disc.process_symbol(-vector_field) + assert disc_neg._disc_state_vector is marker diff --git a/packages/pybamm/tests/unit/test_solvers/test_solution.py b/packages/pybamm/tests/unit/test_solvers/test_solution.py index 95cf1597d8..b81cb19afe 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_solution.py +++ b/packages/pybamm/tests/unit/test_solvers/test_solution.py @@ -512,6 +512,25 @@ def test_observable_computed_lazily(self): assert sol_sum.observable == expected # computed on access assert sol_sum._observable == expected # and cached + def test_vector_field_variable(self, mesh_2d): + # structured 2D FV does not support reading VectorField solution variables + model = pybamm.BaseModel() + var = pybamm.Variable( + "var", domain=["negative electrode", "separator", "positive electrode"] + ) + model.rhs = {var: pybamm.Scalar(-1)} + model.initial_conditions = {var: pybamm.Scalar(1)} + model.variables = {"var": var, "flux": pybamm.VectorField(var, 2 * var)} + + disc = pybamm.Discretisation(mesh_2d, {"macroscale": pybamm.FiniteVolume2D()}) + disc.process_model(model) + solution = pybamm.IDAKLUSolver().solve(model, np.linspace(0, 1, 5)) + + with pytest.raises( + NotImplementedError, match=r"structured 2D finite-volume meshes" + ): + solution["flux"] + def test_add_solutions_different_models(self): # Set up first solution t1 = np.linspace(0, 1) diff --git a/packages/pybamm/tests/unit/test_spatial_methods/test_finite_volume_2d/test_tensor_field.py b/packages/pybamm/tests/unit/test_spatial_methods/test_finite_volume_2d/test_tensor_field.py index 1568b20561..151249b98c 100644 --- a/packages/pybamm/tests/unit/test_spatial_methods/test_finite_volume_2d/test_tensor_field.py +++ b/packages/pybamm/tests/unit/test_spatial_methods/test_finite_volume_2d/test_tensor_field.py @@ -126,6 +126,46 @@ def test_rank2_evaluates_on_edges(self): t = TensorField([[a, b], [c, d]]) assert t.evaluates_on_edges("primary") is False + def test_components_property(self): + """Accessing the components property returns nested structure.""" + a, b = pybamm.Scalar(1), pybamm.Scalar(2) + t = TensorField([a, b]) + assert t.components == [a, b] + + def test_rank1_tuple_index(self): + """Rank-1 tensor accepts single-element tuple index.""" + a, b = pybamm.Scalar(1), pybamm.Scalar(2) + t = TensorField([a, b]) + assert t[(0,)] == a + + def test_rank1_too_many_indices_raises(self): + """Rank-1 tensor raises for multi-element tuple index.""" + a, b = pybamm.Scalar(1), pybamm.Scalar(2) + t = TensorField([a, b]) + with pytest.raises(IndexError, match="Too many indices for rank-1"): + t[(0, 1)] + + def test_rank2_single_element_tuple_returns_row(self): + """Rank-2 tensor with single-element tuple returns row.""" + a, b, c, d = [pybamm.Scalar(i) for i in range(4)] + t = TensorField([[a, b], [c, d]]) + assert t[(0,)] == [a, b] + + def test_rank2_too_many_indices_raises(self): + """Rank-2 tensor raises for 3+ element tuple index.""" + a, b, c, d = [pybamm.Scalar(i) for i in range(4)] + t = TensorField([[a, b], [c, d]]) + with pytest.raises(IndexError, match="Too many indices for rank-2"): + t[(0, 1, 2)] + + def test_rank2_evaluates_on_edges_all_true(self): + """Rank-2 evaluates_on_edges returns True when all components are on edges.""" + a, b, c, d = [pybamm.Scalar(i) for i in range(4)] + t = TensorField([[a, b], [c, d]]) + for child in t.children: + child._evaluates_on_edges = lambda _: True + assert t.evaluates_on_edges("primary") is True + class TestVectorFieldInheritance: """Tests for VectorField inheriting from TensorField.""" @@ -156,6 +196,61 @@ def test_vectorfield_domain_validation(self): with pytest.raises(ValueError, match="same domain"): pybamm.VectorField(a, b) + def test_vectorfield_requires_two_components(self): + """VectorField with fewer than 2 components raises.""" + with pytest.raises(ValueError, match="requires at least 2 components"): + pybamm.VectorField(pybamm.Scalar(1)) + + def test_vectorfield_three_components(self): + """3-component VectorField exposes components by index.""" + a, b, c = pybamm.Scalar(1), pybamm.Scalar(2), pybamm.Scalar(3) + vf = pybamm.VectorField(a, b, c) + assert vf[2] == c + assert vf.n_components == 3 + + def test_vectorfield_evaluates_on_edges_all_true(self): + """VectorField evaluates_on_edges returns True when all on edges.""" + vf = pybamm.VectorField(pybamm.Scalar(1), pybamm.Scalar(2)) + vf.lr_field._evaluates_on_edges = lambda _: True + vf.tb_field._evaluates_on_edges = lambda _: True + assert vf.evaluates_on_edges("primary") is True + + def test_vectorfield_to_casadi(self): + """VectorField _to_casadi concatenates components via vertcat.""" + import casadi + + a, b = pybamm.Scalar(1.0), pybamm.Scalar(2.0) + vf = pybamm.VectorField(a, b) + mx = vf.to_casadi() + assert isinstance(mx, casadi.MX) + f = casadi.Function("f", [], [mx]) + out = f.call([]) + np.testing.assert_array_equal(np.array(out[0]).flatten(), [1.0, 2.0]) + + +class TestComponentAndNorm: + """Tests for the Component and Norm operators on VectorFields.""" + + def test_component_copy(self): + """Copying a Component preserves the index.""" + vf = pybamm.VectorField(pybamm.Scalar(1), pybamm.Scalar(2)) + comp = pybamm.Component(vf, 1) + assert isinstance(comp, pybamm.Component) + assert comp.index == 1 + copy = comp.create_copy() + assert isinstance(copy, pybamm.Component) + assert copy.index == 1 + assert copy == comp + + def test_norm_copy(self): + """Copying a Norm preserves structure.""" + vf = pybamm.VectorField(pybamm.Scalar(1), pybamm.Scalar(2)) + norm = pybamm.Norm(vf) + assert isinstance(norm, pybamm.Norm) + copy = norm.create_copy() + assert isinstance(copy, pybamm.Norm) + assert copy == norm + class TestTensorProduct: """Tests for TensorProduct operator."""