Skip to content

Upgrade SymPy - #193

Merged
yguclu merged 27 commits into
masterfrom
upgrade_sympy
Oct 20, 2026
Merged

yguclu merged 27 commits into
masterfrom
upgrade_sympy

Conversation

@FrederikSchnack

@FrederikSchnack FrederikSchnack commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Update SymPDE to support the maintained SymPy release range (fixes #106):

SymPy >= 1.12, < 1.15

To this end, address compatibility problems caused by stricter symbolic construction, traversal, reconstruction, hashing, and sympification behavior in recent SymPy versions. Along the way, fix two equality bugs in the topology module (fixes #197, fixes #198).

Compatibility issues and fixes

1. Incorrect construction of custom Symbol subclasses

ScalarFunction, VectorFunction, and DifferentialForm were constructed through Expr.__new__ or Basic.__new__ instead of SymPy's Symbol constructor.

Recent SymPy versions expect symbol subclasses to initialize Symbol-specific state, including assumptions, commutativity, and free-symbol behavior. Without this initialization:

  • free_symbols could be incorrect;
  • assumptions were not initialized consistently;
  • scalar and vector functions could have incorrect commutativity;
  • equality and hashing did not follow SymPy's symbol semantics.

The affected objects are now constructed through Symbol.__xnew__.

SymPDE-specific metadata, such as the function space or differential-form degree and dimension, is included through _hashable_content rather than being passed as invalid raw symbol arguments.

Regression tests verify:

  • correct scalar and vector commutativity;
  • correct free_symbols;
  • stable equality and hashing;
  • distinct differential forms for different degree or dimension values.

2. Non-SymPy values stored in Basic.args

Several SymPDE expression classes stored ordinary Python objects directly in their structural arguments, including:

  • strings;
  • integers;
  • None;
  • lists and tuples;
  • dictionaries.

Modern SymPy traverses, hashes, prints, sympifies, and reconstructs every object exposed through Basic.args. Raw Python metadata could therefore cause failures during operations such as:

expr.func(*expr.args)
expr.xreplace(...)
preorder_traversal(expr)

A common structural-argument normalization layer was introduced in sympde.core.basic. The public helper new_basic() constructs a Basic object after normalizing its arguments:

  • strings are stored as SymPy Str objects;
  • integers are sympified as Integer;
  • tuples and lists are stored as SymPy Tuple objects;
  • dictionaries are stored as SymPy Dict objects;
  • optional None values use a private SymPy-compatible sentinel.

The matching public helpers restore_optional_argument(), restore_string_argument(), restore_int_argument(), and restore_container_argument() convert these values back to their historical Python representation when accessed through public properties. This preserves the existing SymPDE API while keeping expression trees valid for SymPy.

The normalization is applied to objects including domains, boundaries, interfaces, equations, matrix elements, traces, one-dimensional forms, and multipatch mappings.

3. Expressions could not be reconstructed from func and args

SymPy assumes that symbolic expressions can generally be rebuilt using:

expr.func(*expr.args)

This mechanism is used internally by substitution, xreplace, simplification, and other tree transformations.

A number of SymPDE constructors did not satisfy this contract because:

  • structural metadata was omitted from args;
  • constructors expected keyword-only arguments;
  • reconstructed arguments were SymPy containers instead of Python containers;
  • subclass constructors interpreted structural arguments as user-facing arguments;
  • optional arguments were not converted back from their symbolic representation.

Constructors were updated to accept and restore normalized structural arguments. Reconstruction now retains information such as:

  • domain interiors and boundaries;
  • mappings and logical domains;
  • interface orientations;
  • domain connectivity;
  • line, square, and cube bounds, and the coordinates of NCube, Line, Square, and Cube;
  • equation boundary conditions and constraints;
  • one-dimensional form axes and weights;
  • multipatch mapping dictionaries.

Where possible, the constructor signatures now follow the order of args, so that no separate reconstruction code path is needed:

  • Domain(name, interiors, boundaries, mapping, logical_domain, connectivity, *, dim): the parameters may now be passed positionally, dim is keyword-only, and connectivity also accepts an iterable of Interface objects (which is what is stored in args).
  • Interface(name, bnd_minus, bnd_plus, ornt, mapping, logical_domain): ornt, mapping, and logical_domain may now be passed positionally, in this order.
  • The one-dimensional forms (Mass, Stiffness, Advection, AdvectionT, Bilaplacian) store only axis and weight in their args; their name is now a class attribute.

NCube, Line, Square, and Cube keep their user-facing signatures. When SymPy rebuilds them, the stored Domain arguments are forwarded to Domain.__new__, and the coordinates are recreated. Their docstrings describe this behavior.

Regression tests now exercise both reconstruction and substitution:

rebuilt = expr.func(*expr.args)
replaced = expr.xreplace({...})

4. Multipatch mappings used raw dictionaries as symbolic arguments

MultiPatchMapping previously placed a Python dictionary directly in Basic.args and implemented a custom __hash__. Moreover, it inherited _hashable_content from Mapping, which accesses attributes that MultiPatchMapping does not have. As a result, comparing two multipatch domains with == raised an AttributeError (fixes #197).

Recent SymPy operations expect structural arguments themselves to be symbolic and reconstructible. This caused problems during hashing, replacement, and rebuilding.

The mapping dictionary is now stored structurally as a SymPy Dict, while the public mappings property continues to return a regular Python dictionary. Equality and hashing are derived from the normalized structural arguments instead of a separate custom implementation.

Tests verify that multipatch mappings survive both reconstruction and xreplace.

5. Inconsistent equality and hashing

Several classes defined a custom __hash__ that did not match their equality:

  • Function symbols were primarily identified by their printed name. As a result, functions with the same name but belonging to different function spaces could compare as equal and collapse during SymPy simplification. For example, an expression conceptually equivalent to u_from_V + u_from_W could be simplified as though both functions were the same symbol.
  • NCubeInterior objects with the same name but different parametric bounds compared equal, but had different hashes (fixes NCubeInterior equality ignores parametric bounds, and is inconsistent with its hash #198).

Function spaces now define structural equality from their name, domain, shape, and kind. Scalar and vector function symbols include their function space in _hashable_content. NCubeInterior stores min_coords and max_coords in its args.

The custom __hash__ methods of BasicFunctionSpace, ScalarFunction, VectorFunction, NCubeInterior, and MultiPatchMapping were removed in favor of SymPy's Basic.__hash__, which is computed from _hashable_content and cached. Integral now reuses CalculusFunction.__hash__.

This ensures that:

  • equivalent spaces compare and hash equally;
  • different spaces remain distinct;
  • functions with the same name in different spaces remain distinct symbols;
  • additions do not incorrectly combine functions from different spaces;
  • mapped and logical test functions retain the correct domain identity;
  • patches with different bounds compare unequal, consistently with their hash.

6. Constructor postprocessing returned a Python list

The integral multiplication postprocessor wrapped a transformed IntAdd expression in a Python list before passing it back to Mul.

Recent SymPy versions strictly sympify constructor-postprocessor results and reject this list during form-linearity checks.

The postprocessor now returns the transformed IntAdd expression directly:

IntAdd(...)

instead of:

[IntAdd(...)]

7. Legacy SymPy compatibility helpers were outdated

SymPDE carried local copies of compatibility utilities for:

  • is_sequence;
  • Python 2-style metaclass construction.

These helpers were replaced with maintained functionality:

  • use sympy.utilities.iterables.is_sequence;
  • use native Python 3 metaclass declarations.

VectorFunction handling remains explicit where its indexed behavior differs from ordinary Python sequences.

The compatibility module itself remains temporarily available because it is still exported by the legacy package namespace. It is removed by the subsequent public-API PR.

8. Domain subclasses were not printed correctly in LaTeX

The custom LaTeX printer only implemented _print_Domain. Some reconstructed domain-related objects are subclasses of BasicDomain rather than Domain, causing modern SymPy's printer dispatch to miss the custom representation.

A _print_BasicDomain implementation was added so domain interiors and interfaces consistently render using their symbolic names.

Regression tests cover multipatch interiors and interfaces.

9. Creating a Domain from a connectivity only

The documentation of Domain stated that a domain can be created from its name and connectivity only, but doing so failed with TypeError: object of type 'NoneType' has no len(). Domain now raises a ValueError when neither interiors nor dim is given, and the docstrings of the class and of its constructor were corrected accordingly.

Code cleanup

In the modified modules and their tests, the # ... separator comments were replaced by blank lines, and commented-out code was removed.

Dependency and CI changes

  • Raise the minimum supported SymPy version from 1.5 to 1.12.
  • Support SymPy releases below 1.15.
  • Keep the normal CI matrix on the newest version allowed by the dependency range.
  • Add a dedicated Python 3.11 job pinned to SymPy 1.12.
  • Run the complete test suite in both minimum- and maximum-supported dependency configurations.

The upper bound remains intentionally below SymPy 1.15 until that release range receives a separate compatibility review.

Validation

The test suite passes with the upgraded symbolic implementation (SymPy 1.14.0):

175 passed, 2 skipped

New regression coverage includes:

  • symbol assumptions and commutativity;
  • free_symbols behavior;
  • structural equality and hashing;
  • equality of NCubeInterior objects with different parametric bounds (NCubeInterior equality ignores parametric bounds, and is inconsistent with its hash #198);
  • equality of mapped multipatch domains that differ only in the interface orientation (Comparing multipatch domains with == raises AttributeError #197);
  • functions with identical names in different spaces;
  • differential forms with different degree or dimension;
  • SymPy-compatible expression arguments;
  • func(*args) reconstruction, including multipatch domains, one-dimensional forms, and the coordinates of NCube and its subclasses;
  • xreplace substitutions;
  • multipatch mapping reconstruction;
  • domain and interface LaTeX output;
  • integral multiplication postprocessing.

@FrederikSchnack FrederikSchnack mentioned this pull request Sep 29, 2026
Base automatically changed from update_docs to master October 1, 2026 16:27
Construct function symbols and differential forms through Symbol so that SymPy initializes their assumptions and free-symbol state. Keep their SymPDE metadata in structural identity without exposing non-Basic values through args.

Refs #106
Keep the transformed IntAdd as a SymPy expression instead of wrapping it in a Python list. Modern SymPy strictly sympifies constructor postprocessor results and rejected the list while checking form linearity.

Refs #106
Convert string, numeric, container, and optional metadata stored in custom Basic.args to SymPy objects. Restore the public Python values through properties so callers keep the existing API while traversal, printing, hashing, and substitution can safely inspect expression trees.

Also preserve integer DifferentialForm dimensions and the historical name-based identity of function symbols.

Refs #106
Raise the minimum dependency to SymPy 1.12 for supported Python versions and cap it below 1.15 until the next compatibility review. Keep the regular CI matrix on the newest allowed release and add a Python 3.11 job pinned to the minimum.

Refs #106
Use maintained SymPy utilities and native metaclass syntax. The compatibility module remains temporarily available to the legacy package exports until the public API layer removes them.
Accept normalized SymPy arguments in topology, equation, tensor-form, and multipatch-mapping constructors. Preserve structural metadata during rebuilding and cover func/xreplace round trips with regression tests.
Give function spaces structural equality and include them in scalar and vector function hashes. Equal names in different spaces now remain distinct symbols instead of collapsing during SymPy simplification.

@yguclu yguclu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good job!

Since we have set default_role = 'py:obj' in doc/conf.py, I believe that we can use single backtick marks in all the (new) docstrings.

I have left a few additional comments below

Comment thread sympde/core/basic.py Outdated
Comment thread sympde/core/basic.py Outdated
Comment thread sympde/core/basic.py
Comment thread sympde/topology/space.py Outdated
Comment thread sympde/topology/basic.py
Comment thread sympde/topology/tests/test_logical_expr.py Outdated
Comment thread sympde/topology/domain.py Outdated
Comment thread sympde/topology/domain.py
Comment thread sympde/expr/evaluation.py
Comment thread sympde/topology/tests/test_mapping.py
Route _restore_string_argument through _restore_optional_argument like the other restore helpers, so a stored None name is returned as None instead of the internal placeholder.
Replace the remaining _is_none_argument checks with the equivalent restore helpers and inline the placeholder check in _restore_optional_argument.
FrederikSchnack and others added 7 commits October 19, 2026 11:01
Co-authored-by: Yaman Güçlü <yaman.guclu@gmail.com>
Drop the leading underscore from new_basic and the restore_*_argument helpers, which are used outside sympde.core.basic. Module-internal helpers stay private.
Replace the custom Integral.__hash__ with the inherited SymPy hash, which already hashes _hashable_content(). It is assigned explicitly because Integral overrides __eq__.
Co-authored-by: Yaman Güçlü <yaman.guclu@gmail.com>
Make the positional parameters of Domain.__new__ follow the order of self.args, so func(*args) reconstructs a domain without a separate code path. dim becomes keyword-only, and connectivity also accepts an iterable of Interface objects.

Require interiors or dim up front instead of interiors or connectivity, which failed later on len(None), and update the class and constructor docstrings accordingly. Add a test for rebuilding multipatch domains.
Rebuilding NCube, Line, Square and Cube via func(*args) skipped setting _coordinates, so the rebuilt object fell back to BasicDomain.coordinates, which returns a list and is limited to three dimensions. Route the reconstruction through a shared NCube._rebuild that sets the coordinates from the rebuilt dimension.

Document in the constructors that the positional parameters receive the stored Domain arguments when rebuilding, and add a test comparing coordinates before and after func(*args), including a 4D NCube.
The name of a Basic1dForm subclass is fully determined by the class, so store it as a class attribute instead of in args. The constructor signature (axis, weight) now matches args, which removes the structural_args branches from all subclasses.

Add a test covering name, axis, weight, printing, func(*args) and that different subclasses stay distinct.
Purely a formatting change, with no change in behavior. Each "# ..." separator line in the sympde package becomes two empty lines. Blank lines directly after a block opener or at the start or end of a file are dropped.
The Sphinx configuration sets default_role = 'py:obj', so single backticks render as code and link to documented objects. Also mark Domain.__new__ and NCube.__new__ as references.
Follow-up to the separator cleanup, again purely formatting. Inside indented code, the former "# ..." separators now become one empty line instead of two (pycodestyle E303), while module-level gaps keep two. Also remove the remaining separators that were written with a non-breaking space.
The bounds are now part of args, so the custom __hash__ that added them again is redundant. NCubeInterior does not override __eq__, so it can fall back to Basic.__hash__.
Add the user-visible changes made while addressing the review: the new Domain argument order and connectivity input, positional Interface arguments, the new args layout of the 1D forms, the clearer Domain error and the new public helpers in sympde.core.basic.
Comment thread sympde/expr/tests/test_tensor_2d.py Outdated

@yguclu yguclu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While double-checking the PR description (which I have now updated, please have a look), I noticed that there is no regression test for #198. The existing test_hash only checks that the hashes differ, which was already the case before this PR. Could you add a test like this?

def test_ncube_interior_equality_includes_bounds():
    P = Square('P', bounds1=(0, 1), bounds2=(0, 1)).interior
    Q = Square('P', bounds1=(0, 2), bounds2=(0, 1)).interior
    R = Square('P', bounds1=(0, 1), bounds2=(0, 1)).interior

    assert P != Q
    assert P == R

Optionally, the reproducer from #197 (two mapped multipatch domains differing only in the interface orientation) would also make a good test; at the moment #197 is only covered indirectly by test_multipatch_mapping_reconstruction.

When adding the test(s), please also update the "Validation" section of the PR description (test count and list of new regression coverage).

Check that NCubeInterior objects with different parametric bounds compare unequal (#198), and that mapped multipatch domains differing only in the interface orientation can be compared and compare unequal (#197). Both tests fail on master.
@FrederikSchnack

Copy link
Copy Markdown
Contributor Author

Thanks for catching this! Added both in 8bc7078: test_ncube_interior_equality_includes_bounds for #198 and test_multipatch_domain_equality with the reproducer from #197. Both fail on master. I also updated the "Validation" section of the description (175 passed, 2 skipped, and the two new regression tests).

@yguclu
yguclu merged commit 3be5d9e into master Oct 20, 2026
16 checks passed
@yguclu
yguclu deleted the upgrade_sympy branch October 20, 2026 07:25
yguclu added a commit that referenced this pull request Oct 20, 2026
Adapt the multipatch changes to the conventions introduced by #193 and #194:

- Import objects from their defining modules in the tests and in
  multipatch_gallery.py, since the package initializers are now empty.
- Empty sympde/utilities/__init__.py again, and export plot_domain in
  sympde.api from sympde.utilities.plotting.
- Restore the version of sympde/utilities/utils.py from this branch, which
  only contains lambdify_sympde: the merge had brought back the old
  plotting functions.
- Allow passing mapping and logical_domain of an Interface positionally,
  so that it can be reconstructed from its args.
- Use orientation instead of ornt in test_structural_arguments_are_sympy_objects.
@yguclu yguclu mentioned this pull request Oct 20, 2026
1 task
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants