Skip to content

Update Multipatch API - #188

Open
FrederikSchnack wants to merge 25 commits into
masterfrom
new_multipatch_api
Open

FrederikSchnack wants to merge 25 commits into
masterfrom
new_multipatch_api

Conversation

@FrederikSchnack

@FrederikSchnack FrederikSchnack commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Generalize symbolic multipatch topology

Summary

This PR removes the assumption that connected faces have the same normal axis and
properly defines orientations for 1D, 2D, and 3D interfaces in SymPDE's multipatch topology.

It also adds stable topology inspection APIs, preserves interface metadata
through mapping and serialization, supports repeated and self-interfaces,
computes shared vertices in 2D and 3D, and introduces reusable topology
plotting and multipatch gallery modules, that were exclusively in PSYDAC before.

The changes are confined to SymPDE, this introduces necessary changes in PSYDAC that are already mostly prepared.

Fixes #73, fixes #140, fixes #143, fixes #144, fixes #114.

Interface topology

Domain.join() accepts each interface as:

(minus_side, plus_side, orientation)
(minus_side, plus_side, orientation, name)

Each side is (patch, normal_axis, extremity), where patch is a patch object
or its position in the patches sequence and extremity is -1 or +1.
interfaces= is an alias for connectivity=; passing both is an error.

For example, two cube faces with different normal axes can be joined as:

from sympde.api import Cube, Domain

A = Cube('A')
B = Cube('B')

domain = Domain.join(
    patches=[A, B],
    interfaces=[(
        (A, 0, +1),
        (B, 1, -1),
        (-1, +1, -1),
    )],
    name='Omega',
)

Orientation is read from the ordered minus side to the ordered plus side:

Patch dimension Orientation Meaning
1D None An endpoint has no tangential coordinate
2D +1 or -1 Preserve or reverse the edge coordinate
3D (flag, sign1, sign2) Preserve/exchange the two face axes and orient each correspondence

All 3D entries are +1 or -1. flag=+1 preserves the order of the two
tangential axes; flag=-1 exchanges them.

Interface.axis_map exposes the decoded, dimension-independent
correspondence as (minus_axis, plus_axis, direction) entries. For the
example above:

interface = next(iter(domain.interface_map.values()))

assert interface.orientation == (-1, +1, -1)
assert interface.axis_map == ((1, 2, +1), (2, 0, -1))

The face-normal correspondence remains available from
interface.minus.axis and interface.plus.axis; it is not repeated in
axis_map. This is the orientation format currently and later used in PSYDAC.

Supported topology

The joined-domain implementation now supports:

  • interfaces between faces with different normal axes;
  • all two edge orientations and all eight quadrilateral-face orientations;
  • more than one interface between the same pair of patches;
  • periodic and self-interfaces;
  • explicit interface names, preserved during serialization;
  • induced subdomains that retain internal interfaces and expose cut
    interfaces as exterior boundaries; and
  • transitive shared-vertex classes in both 2D and 3D.

Generated names for repeated patch pairs use A|B, A|B#2, and so on.
They are numbered in the order of the faces, so they do not depend on the
order of the connectivity. For mapped patches they are built from the logical
patch names, and a mapped interface and its logical interface have the same
name. Explicit names are validated globally before generated names are
assigned. The interfaces of a domain are ordered by name, so that a domain
does not depend on the order of its connectivity and equals itself after an
export/from_file round trip.

Domain.join() rejects patches with different physical dimensions (#140),
patches of which only some have a mapping, interfaces that join a face with
itself, and faces that are part of more than one interface.

An empty one-patch join still returns the original patch. A one-patch join
with declared connectivity follows the normal join path, allowing a genuine
self-interface. Passing an already joined multipatch domain as one atomic
patch is rejected explicitly.

Mapped domains preserve the logical interface, compact orientation,
InterfaceMapping, and patch mappings. Interface measures use the minus side
as their canonical parameterization instead of assuming one shared normal
axis.

Stable inspection API

The following properties provide cardinality-stable views:

  • domain.patches: tuple of top-dimensional patches;
  • domain.interface_map: interface-name-to-Interface mapping;
  • domain.exterior_sides: tuple of exterior faces;
  • domain.shared_vertices: tuple of shared-vertex classes;
  • Boundary.patch: owning patch; and
  • Boundary.normal_axis: face-normal logical axis.

PatchVertex and SharedVertex are descriptive aliases for the existing
patch-corner and shared-corner symbolic types.

The established domain.interior, domain.boundary, and domain.corners
properties remain available.

Serialization

Serialized interfaces now always contain the minus side, plus side, and
compact orientation. Interface dictionary keys are restored as interface
names instead of being regenerated.

Exterior boundaries use one list representation, including an empty list for
fully closed domains. The reader still accepts the historical single-boundary
dictionary and can derive missing boundary metadata from connectivity.
Stored exterior boundaries are checked against the derived topology.

Domains whose patches are InteriorDomain objects without a dtype (#144)
have no parametric box from which the boundary could be derived. Their
exterior boundaries and interfaces are restored as they were stored,
including boundaries without an axis or extremity. Such patches are only
supported without a mapping.

Files whose interfaces omit orientation are rejected because orientation
cannot be inferred reliably from axes or extremities. Such files must be
regenerated or migrated with an explicitly confirmed orientation for every
interface.

Topology inspection and plotting

The new module sympde.utilities.plotting provides:

  • collect_topology_vertices();
  • format_topology();
  • print_topology(); and
  • plot_domain().

collect_topology_vertices(), print_topology() and plot_domain() are also
available in sympde.api. The old plotting functions in
sympde.utilities.utils are removed.

plot_domain() handles ordinary and topology-annotated 2D and 3D plots and
returns its Matplotlib figure. Topology mode displays patch identities,
interface sides, signed tangential-axis correspondences, and vertex
incidence. Matplotlib is only imported when a domain is plotted, so importing
sympde.utilities.plotting or sympde.api does not load it.

Multipatch gallery

This formerly was in psydac/feec/multipatch_domain_utilities.py.

sympde.topology.multipatch_gallery provides explicit symbolic builders for various multipatch domains and includes a integrated plotting function.

The gallery can be plotted from Python with plot_multipatch_domain() or run
as a module:

python -m sympde.topology.multipatch_gallery two_cube \
    --topology --no-show --output two_cube.png

TransposedPolarMapping is now part of SymPDE's analytical mappings because it is
a reusable coordinate transformation rather than a discretization detail.

Breaking changes

Previous API or data Replacement
Boundary.join(other, ornt=...) Boundary.join(other, orientation)
Interface(..., ornt=...) Interface(..., orientation)
interface.ornt interface.orientation
interface.axis interface.minus.axis and interface.plus.axis
Edge Boundary
interface connectivity dictionaries (minus, plus, orientation[, name]) tuples
serialized interface without orientation explicit compact orientation
plot_2d(), plot_3d(), plot_2d_single_patch(), plot_3d_single_patch() plot_domain()
sympde.utilities.utils.plot_domain sympde.api.plot_domain

No default orientation is introduced. Callers must state the intended
topology explicitly.

Integration with the public API and the SymPy upgrade

This branch was merged with master after #193 (SymPy upgrade) and #194
(public API), and adapted to their conventions:

  • Interface stores mapping and logical_domain in its args and accepts
    them positionally, so that it can be reconstructed with
    interface.func(*interface.args).
  • The new modules, tests and documentation import objects from their defining
    modules or from sympde.api, and the package initializers stay empty.
  • The docstring of Domain.join() keeps the doctests of this branch, which
    replace the code block added in Change documentation to Github Pages #191.
  • The CHANGELOG describes the changes of this PR. Duplicated and outdated
    entries in its "Unreleased" section are removed.

TODO

Current limitations

  • SymPDE validates topology but does not numerically prove that mapped faces
    coincide or satisfy the declared parameterization.
  • In the solid torus of the gallery, the faces collapsed onto the centreline
    remain exterior sides and must be excluded when imposing boundary
    conditions.
  • Compact orientations are defined for logical dimensions 1, 2, and 3.

Some plots

1_three_patch_interior_vertex 2_two_patch_twisted_strip 3_hollow_torus 4_three_patch_interior_edge

Represent interface orientation explicitly and expose a signed tangential axis map that supports cross-axis 3D faces.

Support repeated and self interfaces, stable multipatch views, induced subdomains, dimension-independent shared vertices, mapped interface metadata, collision-safe names, and robust topology serialization. Tighten public validation and cover the new contract with logical, mapped, subset, periodic, and round-trip tests.
Move domain plotting into a dedicated lazy-loaded module and provide shared 2D/3D rendering with optional interface, axis-map, and vertex annotations.

Add textual topology inspection, reusable 2D/3D gallery domain builders, an executable gallery CLI, and integration tests without making Matplotlib an import-time dependency of sympde.
Use MathJax for rendered mathematics, configure the existing FEEC bibliography explicitly, and remove obsolete imgmath and LaTeX configuration.

Also normalize the TerminalExpr parameter documentation so strict autodoc builds remain warning-free.
Add a user guide for constructing, inspecting, validating, plotting, subsetting, and serializing logical and mapped multipatch domains in 1D, 2D, and 3D.

Document the downstream axis-map contract, intentional API and file-format migrations, Edge removal, current limitations, and validation responsibilities.
Comment thread sympde/topology/domain.py
Comment on lines +697 to +698
if not all(p.dim == patches[0].dim for p in patches):
raise ValueError('all patches must have the same logical dimension')

@yguclu yguclu Oct 15, 2026 •

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.

Could you also check that all patches have the same physical dimension pdim? join currently accepts e.g. a planar square joined with a surface in 3D, which gives an inconsistent domain (see the updated issue #140, which this PR closes):

A = Mapping('FA', ldim=2, pdim=2)(Square('A'))
B = Mapping('FB', ldim=2, pdim=3)(Square('B'))
Domain.join([A, B], [((0, 0, 1), (1, 0, -1), 1)], 'Omega')   # accepted

A patch without a mapping has the same physical and logical dimension:

Suggested change
if not all(p.dim == patches[0].dim for p in patches):
raise ValueError('all patches must have the same logical dimension')
raise ValueError('all patches must have the same logical dimension')
pdims = {p.mapping.pdim if p.mapping is not None else p.dim for p in patches}
if len(pdims) > 1:
raise ValueError('all patches must have the same physical dimension')

Please also mention this requirement in the docstring, next to the one on the logical dimension.

A unit test in the PR's test suite, joining a pdim=2 and a pdim=3 patch and expecting ValueError, would also be a good idea.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, done in 1352c44: join now rejects patches with different physical dimensions, the requirement is in the docstring and in the multipatch docs page, and test_domain_join_rejects_different_physical_dimensions covers it.

@yguclu yguclu mentioned 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.
Move the Matplotlib imports from the top of sympde/utilities/plotting.py into
_plot_domain_2d and _plot_domain_3d, so that importing the module (or
sympde.api) does not load Matplotlib (see #180).
Import from sympde.api in the examples of Interface and Domain.join, and
refer to Domain by its defining module in the plotting docstrings, since
the package initializers are empty after #194.
Import from sympde.api in the examples of doc/multipatch.rst, and add
collect_topology_vertices and print_topology to sympde.api, since the
documentation uses them. Point the migration table to sympde.api.plot_domain.
Describe the changes of this PR, and remove duplicated and outdated entries
from the Unreleased section.
Resolve the merge conflict in the docstring of Domain.join: the doctests of
this branch replace the code block that #191 had added on master, since
they render correctly and show the same 2D connectivity.
@yguclu

yguclu commented Oct 20, 2026

Copy link
Copy Markdown
Member

Thanks for this PR!

I have merged master into your branch and tried to resolve all the conflicts... I hope I have not introduced big problems! Accordingly, I have updated the PR description (see the new section "Integration with the public API and the SymPy upgrade"). I also removed the disclaimer: since the PR is squash-merged, the description becomes the commit message, where it is not really useful. I left the section "Some plots" where it is, but I will make sure that it is not included in the final merge commit.

Could you also have a look at #140? Domain.join still does not check that all patches have the same physical dimension pdim

@yguclu

yguclu commented Oct 22, 2026

Copy link
Copy Markdown
Member

Will this PR also close #114?

@FrederikSchnack

Copy link
Copy Markdown
Contributor Author

Will this PR also close #114?

Yes, it should. See the two patch twisted strip example. I will update the description.
I will get back to this PR now, and also look into #140. And I'm not sure if this is also a good place for #200 .

@campospinto
campospinto self-requested a review October 22, 2026 12:48
@yguclu yguclu self-assigned this Oct 22, 2026
@yguclu

yguclu commented Oct 22, 2026

Copy link
Copy Markdown
Member

Will this PR also close #114?

Yes, it should. See the two patch twisted strip example. I will update the description. I will get back to this PR now, and also look into #140. And I'm not sure if this is also a good place for #200 .

Issue #200 is explicitly fixed by the small PR #201, which can be merged very soon. I will ask for a small check to be added to todict in the same PR, which should not conflict with your (much larger) PR

@yguclu

yguclu commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

I have just created the new issue #203. Naturally, that should be fixed in this PR once #201 is merged

Joining a planar patch with a surface in 3D created an inconsistent
domain. Check that all patches have the same physical dimension, which
is the logical dimension for a patch without a mapping, and document
the requirement in the docstring and in the multipatch documentation.

Fixes #140.
A domain whose patches are InteriorDomain objects without a dtype was
exported with `dtype: [None, None]`, and reading it back failed at
`assert all(dtype)`. Such patches have no parametric box from which
Domain.join could derive the boundary, and their boundaries may have no
axis or extremity.

Rebuild these domains directly from the stored interiors, boundaries
and interfaces, keeping their names and orientations. A file in which
only some patches have a dtype is rejected, and mapped patches without
a dtype are not supported.

Fixes #144.
The interfaces were stored in `args` in the order in which they were
given, so that two joins of the same patches with the interfaces in a
different order compared unequal. Since Domain.from_file reads them
sorted by name, a domain also compared unequal to itself after an
export/from_file round trip whenever the interface names were not in
alphabetical order.

Store the interfaces sorted by name, and reject an iterable of
interfaces with duplicate names, which used to silently drop all but
the last one.
The logical interface names (A|B, A|B#2) were generated separately from
the physical ones (F(A)|G(B), ...), in the order of the connectivity,
and were not serialized. Domain.from_file reads the interfaces sorted by
name and passes their stored names as explicit names, so after a round
trip the logical names could end up on different interfaces. Explicit
names were also not given to the logical interfaces.

Generate one name per interface from the logical patch names, and give
it to both the mapped and the logical interface. The names are then
preserved by export/from_file, and joining mapped patches names the
interfaces like mapping a joined logical domain.
Domain.shared_vertices and Domain.corners failed with an AttributeError
when the interfaces join InteriorDomain objects without a parametric
box, such as the domains read by Domain.from_file without a dtype. The
vertices of such patches are not defined, so raise a ValueError that
says so.
Domain.join accepted an interface joining a face with itself, and a face
used in two interfaces, which gave an inconsistent topology and wrong
shared-vertex classes. Reject both, and document the rule in the
docstring and in the multipatch documentation.

The test of a single exterior side relied on a face joined with itself.
Since every interface of a joined domain uses two faces, use a
topological domain for it, and move the check of missing boundary
metadata to its own test with a valid join.
Domain.join accepted patches of which only some have a mapping. The
joined domain then had no mapping and no logical domain, so the mappings
of the mapped patches were silently dropped from the domain and its
interfaces. Require either all patches or none to have a mapping.
In the solid torus built by build_torus_3d(hollow=False), the faces at
minor radius zero collapse onto the centreline of the tube, but they
remain exterior sides of the domain. They cannot be left out of the
boundary, since Domain.join and Domain.from_file derive it from the
connectivity, and joining them as an interface would not describe the
collapse. Say in the docstring and in the documentation that they must
be excluded when imposing boundary conditions.
Repeated interfaces between the same pair of patches were numbered
A|B, A|B#2, ... in the order of the connectivity, so that two joins
with the same unnamed interfaces in a different order still gave
different domains. Number them in the order of their faces instead.
The 2D and 3D plots were each drawn by one long function, which mixed
the sampling of the geometry with Matplotlib calls, duplicated the
figure, legend and vertex code, and spread colours, line widths and
font sizes over the code. Several things went wrong in the plots:

- the plus side of an interface was drawn dashed exactly on top of its
  minus side, in the same colour, so that it and its arrow were hidden,
  and the labels I0- and I0+ overlapped;
- the text describing the axis maps was placed at a fixed position, so
  that it overlapped the legend, ran off the figure for many
  interfaces, and was cut off on user-supplied axes;
- patch colours repeated after 9 patches, also between neighbours;
- a NumPy integer refinement was rejected.

Now a plot model is built first, with the sampled faces, the exterior
faces, the interfaces and the vertices, and the 2D and 3D drawing
functions only draw it, with shared helpers and one style dictionary.
The mappings are evaluated on arrays instead of point by point.

The plots look different:

- each interface side is drawn inside its own patch, the minus side
  solid and the plus side dashed, with a visible arrow head, and with a
  single label when the two sides coincide;
- the exterior boundary is drawn in black and the other edges in grey;
- patches get colours from the Okabe-Ito palette, which is safe for
  colour-blind readers, and neighbouring patches get different colours;
- boundary vertices are white and interior vertices black;
- the legend explains the line and vertex styles below the plot, and
  the axis maps are left to print_topology();
- the 3D box keeps a minimal height, so that a torus is not flattened.

The signature of plot_domain() and its return value are unchanged.
Codacy reported the bare attribute access inside pytest.raises as a
statement without effect.
The arrows of both interface sides followed their own tangential
coordinates, so that the arrows of a reversing interface, such as the
one of the twisted strip, pointed the same way and the twist could not
be seen. Draw the arrows of the plus side along the images of the
coordinates of the minus side instead: in 2D the plus arrow is reversed
for a reversing interface, and in 3D all arrows are labelled with the
coordinates of the minus face, so that the arrows of the two faces
coincide when the declared orientation matches the geometry.

This branch has not been deployed

No deployments
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