From 3de47004fcba994208285d29c31a13899bda077d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:28:02 +0000 Subject: [PATCH 01/19] Initial plan From aad93d5c4e2dfd5cf5b42c6c35982d8a35a7db27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:33:14 +0000 Subject: [PATCH 02/19] feat: add pyvista plotting helper for species fields --- src/festim/__init__.py | 1 + src/festim/plot.py | 88 +++++++++++++++++++++++++++++ test/test_plot.py | 125 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 src/festim/plot.py create mode 100644 test/test_plot.py diff --git a/src/festim/__init__.py b/src/festim/__init__.py index b2095f861..546fd9895 100644 --- a/src/festim/__init__.py +++ b/src/festim/__init__.py @@ -72,6 +72,7 @@ from .mesh.mesh import Mesh from .mesh.mesh_1d import Mesh1D from .mesh.mesh_from_xdmf import MeshFromXDMF +from .plot import plot from .problem import ProblemBase from .reaction import Reaction from .settings import Settings diff --git a/src/festim/plot.py b/src/festim/plot.py new file mode 100644 index 000000000..9a1998685 --- /dev/null +++ b/src/festim/plot.py @@ -0,0 +1,88 @@ +from pathlib import Path + +from festim.species import Species + + +def _normalise_fields(field: Species | list[Species]) -> list[Species]: + if isinstance(field, Species): + return [field] + if isinstance(field, list) and all(isinstance(f, Species) for f in field): + return field + raise TypeError("field must be of type festim.Species or a list of festim.Species") + + +def _get_solution(field: Species, subdomain=None): + if subdomain is None: + if field.post_processing_solution is None: + raise ValueError( + f"Species {field.name} has no post_processing_solution to plot." + ) + return field.post_processing_solution + + if not field.subdomain_to_post_processing_solution: + raise ValueError( + f"Species {field.name} has no subdomain post-processing solutions." + ) + if subdomain not in field.subdomain_to_post_processing_solution: + raise ValueError( + f"Species {field.name} has no post-processing solution on subdomain " + f"{subdomain}." + ) + return field.subdomain_to_post_processing_solution[subdomain] + + +def _make_ugrid(solution, pyvista_module): + from dolfinx import plot as dolfinx_plot + + topology, cell_types, geometry = dolfinx_plot.vtk_mesh(solution.function_space) + u_grid = pyvista_module.UnstructuredGrid(topology, cell_types, geometry) + u_grid.point_data["c"] = solution.x.array.real + u_grid.set_active_scalars("c") + return u_grid + + +def plot( + field: Species | list[Species], + subdomain=None, + filename: str | Path | None = None, + show_edges: bool = False, + **kwargs, +): + """ + Plot one or several species fields with pyvista. + + Args: + field: one species or a list of species. + subdomain: optional volume subdomain used in mixed-domain problems. + filename: optional output image path. If provided, a screenshot is saved. + show_edges: whether to show mesh edges. + **kwargs: additional arguments forwarded to ``pyvista.Plotter.add_mesh``. + """ + try: + import pyvista + except ImportError as error: + raise ImportError( + "pyvista is required for plotting. Install it with `pip install pyvista`." + ) from error + + fields = _normalise_fields(field) + shape = (1, len(fields)) if len(fields) > 1 else None + plotter = pyvista.Plotter(shape=shape) + + for i, spe in enumerate(fields): + if len(fields) > 1: + plotter.subplot(0, i) + + solution = _get_solution(spe, subdomain=subdomain) + u_grid = _make_ugrid(solution, pyvista_module=pyvista) + plotter.add_mesh(u_grid, show_edges=show_edges, **kwargs) + plotter.view_xy() + if spe.name: + plotter.add_text(spe.name, font_size=12) + + if filename is not None: + plotter.screenshot(str(filename)) + elif not pyvista.OFF_SCREEN: + plotter.show() + + return plotter diff --git a/test/test_plot.py b/test/test_plot.py new file mode 100644 index 000000000..71add32e9 --- /dev/null +++ b/test/test_plot.py @@ -0,0 +1,125 @@ +import importlib +import sys +import types + +import pytest + +import festim as F + + +class FakePlotter: + def __init__(self, shape=None): + self.shape = shape + self.mesh_calls = [] + self.subplot_calls = [] + self.text_calls = [] + self.show_called = False + self.screenshot_filename = None + + def subplot(self, row, col): + self.subplot_calls.append((row, col)) + + def add_mesh(self, grid, **kwargs): + self.mesh_calls.append((grid, kwargs)) + + def view_xy(self): + pass + + def add_text(self, text, font_size): + self.text_calls.append((text, font_size)) + + def show(self): + self.show_called = True + + def screenshot(self, filename): + self.screenshot_filename = filename + + +def _setup_fake_pyvista(monkeypatch, off_screen=False): + fake_pyvista = types.SimpleNamespace( + OFF_SCREEN=off_screen, + Plotter=FakePlotter, + UnstructuredGrid=object, + ) + monkeypatch.setitem(sys.modules, "pyvista", fake_pyvista) + return fake_pyvista + + +def test_plot_single_species(monkeypatch): + _setup_fake_pyvista(monkeypatch, off_screen=False) + plot_module = importlib.import_module("festim.plot") + monkeypatch.setattr(plot_module, "_make_ugrid", lambda solution, pyvista_module: 1) + + species = F.Species("H") + species.post_processing_solution = object() + plotter = F.plot(species, show_edges=True) + + assert plotter.shape is None + assert len(plotter.mesh_calls) == 1 + assert plotter.mesh_calls[0][1]["show_edges"] is True + assert plotter.show_called is True + + +def test_plot_multiple_species_creates_subplots(monkeypatch): + _setup_fake_pyvista(monkeypatch, off_screen=False) + plot_module = importlib.import_module("festim.plot") + monkeypatch.setattr(plot_module, "_make_ugrid", lambda solution, pyvista_module: 1) + + h = F.Species("H") + d = F.Species("D") + h.post_processing_solution = object() + d.post_processing_solution = object() + plotter = F.plot([h, d]) + + assert plotter.shape == (1, 2) + assert plotter.subplot_calls == [(0, 0), (0, 1)] + assert len(plotter.mesh_calls) == 2 + + +def test_plot_subdomain_uses_subdomain_solution(monkeypatch): + _setup_fake_pyvista(monkeypatch, off_screen=False) + plot_module = importlib.import_module("festim.plot") + + used_solutions = [] + + def fake_make_ugrid(solution, pyvista_module): + used_solutions.append(solution) + return 1 + + monkeypatch.setattr(plot_module, "_make_ugrid", fake_make_ugrid) + + vol_1 = F.VolumeSubdomain(id=1, material=F.Material(D_0=1, E_D=0)) + vol_2 = F.VolumeSubdomain(id=2, material=F.Material(D_0=1, E_D=0)) + sol_1 = object() + sol_2 = object() + h = F.Species("H") + h.subdomain_to_post_processing_solution = {vol_1: sol_1, vol_2: sol_2} + + F.plot(h, subdomain=vol_2) + assert used_solutions == [sol_2] + + +def test_plot_with_filename_saves_screenshot(monkeypatch, tmp_path): + _setup_fake_pyvista(monkeypatch, off_screen=True) + plot_module = importlib.import_module("festim.plot") + monkeypatch.setattr(plot_module, "_make_ugrid", lambda solution, pyvista_module: 1) + + species = F.Species("H") + species.post_processing_solution = object() + filename = tmp_path / "out.png" + plotter = F.plot(species, filename=filename) + + assert plotter.screenshot_filename == str(filename) + assert plotter.show_called is False + + +def test_plot_raises_for_invalid_field_type(monkeypatch): + _setup_fake_pyvista(monkeypatch, off_screen=False) + with pytest.raises(TypeError): + F.plot("H") + + +def test_plot_raises_if_no_solution(monkeypatch): + _setup_fake_pyvista(monkeypatch, off_screen=False) + with pytest.raises(ValueError): + F.plot(F.Species("H")) From 845ebecaf0560729cea6c3e4eb75481669675561 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:34:36 +0000 Subject: [PATCH 03/19] chore: address validation feedback for plotting helper --- src/festim/plot.py | 4 ++-- test/test_plot.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/festim/plot.py b/src/festim/plot.py index 9a1998685..0d3e3e59c 100644 --- a/src/festim/plot.py +++ b/src/festim/plot.py @@ -60,10 +60,10 @@ def plot( """ try: import pyvista - except ImportError as error: + except ImportError as import_error: raise ImportError( "pyvista is required for plotting. Install it with `pip install pyvista`." - ) from error + ) from import_error fields = _normalise_fields(field) shape = (1, len(fields)) if len(fields) > 1 else None diff --git a/test/test_plot.py b/test/test_plot.py index 71add32e9..26cdb609a 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -88,8 +88,9 @@ def fake_make_ugrid(solution, pyvista_module): monkeypatch.setattr(plot_module, "_make_ugrid", fake_make_ugrid) - vol_1 = F.VolumeSubdomain(id=1, material=F.Material(D_0=1, E_D=0)) - vol_2 = F.VolumeSubdomain(id=2, material=F.Material(D_0=1, E_D=0)) + material = F.Material(D_0=1, E_D=0) + vol_1 = F.VolumeSubdomain(id=1, material=material) + vol_2 = F.VolumeSubdomain(id=2, material=material) sol_1 = object() sol_2 = object() h = F.Species("H") From 43290d3178e59f4d6bbbc2b81355cf27631e6e9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:35:49 +0000 Subject: [PATCH 04/19] refactor: replace plotting magic numbers with constants --- src/festim/plot.py | 4 +++- test/test_plot.py | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/festim/plot.py b/src/festim/plot.py index 0d3e3e59c..7ddf58bdf 100644 --- a/src/festim/plot.py +++ b/src/festim/plot.py @@ -2,6 +2,8 @@ from festim.species import Species +DEFAULT_TITLE_FONT_SIZE = 12 + def _normalise_fields(field: Species | list[Species]) -> list[Species]: if isinstance(field, Species): @@ -78,7 +80,7 @@ def plot( plotter.add_mesh(u_grid, show_edges=show_edges, **kwargs) plotter.view_xy() if spe.name: - plotter.add_text(spe.name, font_size=12) + plotter.add_text(spe.name, font_size=DEFAULT_TITLE_FONT_SIZE) if filename is not None: plotter.screenshot(str(filename)) diff --git a/test/test_plot.py b/test/test_plot.py index 26cdb609a..7b5d2e2a7 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -6,6 +6,8 @@ import festim as F +MOCK_GRID = object() + class FakePlotter: def __init__(self, shape=None): @@ -48,7 +50,9 @@ def _setup_fake_pyvista(monkeypatch, off_screen=False): def test_plot_single_species(monkeypatch): _setup_fake_pyvista(monkeypatch, off_screen=False) plot_module = importlib.import_module("festim.plot") - monkeypatch.setattr(plot_module, "_make_ugrid", lambda solution, pyvista_module: 1) + monkeypatch.setattr( + plot_module, "_make_ugrid", lambda solution, pyvista_module: MOCK_GRID + ) species = F.Species("H") species.post_processing_solution = object() @@ -63,7 +67,9 @@ def test_plot_single_species(monkeypatch): def test_plot_multiple_species_creates_subplots(monkeypatch): _setup_fake_pyvista(monkeypatch, off_screen=False) plot_module = importlib.import_module("festim.plot") - monkeypatch.setattr(plot_module, "_make_ugrid", lambda solution, pyvista_module: 1) + monkeypatch.setattr( + plot_module, "_make_ugrid", lambda solution, pyvista_module: MOCK_GRID + ) h = F.Species("H") d = F.Species("D") @@ -84,7 +90,7 @@ def test_plot_subdomain_uses_subdomain_solution(monkeypatch): def fake_make_ugrid(solution, pyvista_module): used_solutions.append(solution) - return 1 + return MOCK_GRID monkeypatch.setattr(plot_module, "_make_ugrid", fake_make_ugrid) @@ -103,7 +109,9 @@ def fake_make_ugrid(solution, pyvista_module): def test_plot_with_filename_saves_screenshot(monkeypatch, tmp_path): _setup_fake_pyvista(monkeypatch, off_screen=True) plot_module = importlib.import_module("festim.plot") - monkeypatch.setattr(plot_module, "_make_ugrid", lambda solution, pyvista_module: 1) + monkeypatch.setattr( + plot_module, "_make_ugrid", lambda solution, pyvista_module: MOCK_GRID + ) species = F.Species("H") species.post_processing_solution = object() From e2dae126b5f53e4ee6c96bb133db600840ae80d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:37:07 +0000 Subject: [PATCH 05/19] test: improve plot wrapper assertions and kwargs coverage --- src/festim/plot.py | 4 ++-- test/test_plot.py | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/festim/plot.py b/src/festim/plot.py index 7ddf58bdf..fab957e01 100644 --- a/src/festim/plot.py +++ b/src/festim/plot.py @@ -5,7 +5,7 @@ DEFAULT_TITLE_FONT_SIZE = 12 -def _normalise_fields(field: Species | list[Species]) -> list[Species]: +def _normalize_fields(field: Species | list[Species]) -> list[Species]: if isinstance(field, Species): return [field] if isinstance(field, list) and all(isinstance(f, Species) for f in field): @@ -67,7 +67,7 @@ def plot( "pyvista is required for plotting. Install it with `pip install pyvista`." ) from import_error - fields = _normalise_fields(field) + fields = _normalize_fields(field) shape = (1, len(fields)) if len(fields) > 1 else None plotter = pyvista.Plotter(shape=shape) diff --git a/test/test_plot.py b/test/test_plot.py index 7b5d2e2a7..d9441abaf 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -56,11 +56,12 @@ def test_plot_single_species(monkeypatch): species = F.Species("H") species.post_processing_solution = object() - plotter = F.plot(species, show_edges=True) + plotter = F.plot(species, show_edges=True, opacity=0.5) assert plotter.shape is None assert len(plotter.mesh_calls) == 1 assert plotter.mesh_calls[0][1]["show_edges"] is True + assert plotter.mesh_calls[0][1]["opacity"] == 0.5 assert plotter.show_called is True @@ -124,11 +125,14 @@ def test_plot_with_filename_saves_screenshot(monkeypatch, tmp_path): def test_plot_raises_for_invalid_field_type(monkeypatch): _setup_fake_pyvista(monkeypatch, off_screen=False) - with pytest.raises(TypeError): + with pytest.raises( + TypeError, + match=r"field must be of type festim\.Species or a list of festim\.Species", + ): F.plot("H") def test_plot_raises_if_no_solution(monkeypatch): _setup_fake_pyvista(monkeypatch, off_screen=False) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="has no post_processing_solution to plot"): F.plot(F.Species("H")) From fe4aa8030221808996077dae6f615b52041a4033 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:38:13 +0000 Subject: [PATCH 06/19] chore: polish plot helper and test naming --- src/festim/plot.py | 2 +- test/test_plot.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/festim/plot.py b/src/festim/plot.py index fab957e01..3d901ce6e 100644 --- a/src/festim/plot.py +++ b/src/festim/plot.py @@ -76,7 +76,7 @@ def plot( plotter.subplot(0, i) solution = _get_solution(spe, subdomain=subdomain) - u_grid = _make_ugrid(solution, pyvista_module=pyvista) + u_grid = _make_ugrid(solution, pyvista) plotter.add_mesh(u_grid, show_edges=show_edges, **kwargs) plotter.view_xy() if spe.name: diff --git a/test/test_plot.py b/test/test_plot.py index d9441abaf..1cd84f48d 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -6,7 +6,7 @@ import festim as F -MOCK_GRID = object() +_MOCK_GRID = object() class FakePlotter: @@ -51,7 +51,7 @@ def test_plot_single_species(monkeypatch): _setup_fake_pyvista(monkeypatch, off_screen=False) plot_module = importlib.import_module("festim.plot") monkeypatch.setattr( - plot_module, "_make_ugrid", lambda solution, pyvista_module: MOCK_GRID + plot_module, "_make_ugrid", lambda solution, pyvista_module: _MOCK_GRID ) species = F.Species("H") @@ -69,7 +69,7 @@ def test_plot_multiple_species_creates_subplots(monkeypatch): _setup_fake_pyvista(monkeypatch, off_screen=False) plot_module = importlib.import_module("festim.plot") monkeypatch.setattr( - plot_module, "_make_ugrid", lambda solution, pyvista_module: MOCK_GRID + plot_module, "_make_ugrid", lambda solution, pyvista_module: _MOCK_GRID ) h = F.Species("H") @@ -91,7 +91,7 @@ def test_plot_subdomain_uses_subdomain_solution(monkeypatch): def fake_make_ugrid(solution, pyvista_module): used_solutions.append(solution) - return MOCK_GRID + return _MOCK_GRID monkeypatch.setattr(plot_module, "_make_ugrid", fake_make_ugrid) @@ -111,7 +111,7 @@ def test_plot_with_filename_saves_screenshot(monkeypatch, tmp_path): _setup_fake_pyvista(monkeypatch, off_screen=True) plot_module = importlib.import_module("festim.plot") monkeypatch.setattr( - plot_module, "_make_ugrid", lambda solution, pyvista_module: MOCK_GRID + plot_module, "_make_ugrid", lambda solution, pyvista_module: _MOCK_GRID ) species = F.Species("H") From f67490fd2a103099b8fca2d3b38a52d960723f6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:39:12 +0000 Subject: [PATCH 07/19] test: cover plot edge cases and defaults --- test/test_plot.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/test_plot.py b/test/test_plot.py index 1cd84f48d..832e8cc8e 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -123,6 +123,21 @@ def test_plot_with_filename_saves_screenshot(monkeypatch, tmp_path): assert plotter.show_called is False +def test_plot_with_string_filename_saves_screenshot(monkeypatch): + _setup_fake_pyvista(monkeypatch, off_screen=True) + plot_module = importlib.import_module("festim.plot") + monkeypatch.setattr( + plot_module, "_make_ugrid", lambda solution, pyvista_module: _MOCK_GRID + ) + + species = F.Species("H") + species.post_processing_solution = object() + plotter = F.plot(species, filename="out.png") + + assert plotter.screenshot_filename == "out.png" + assert plotter.show_called is False + + def test_plot_raises_for_invalid_field_type(monkeypatch): _setup_fake_pyvista(monkeypatch, off_screen=False) with pytest.raises( @@ -136,3 +151,34 @@ def test_plot_raises_if_no_solution(monkeypatch): _setup_fake_pyvista(monkeypatch, off_screen=False) with pytest.raises(ValueError, match="has no post_processing_solution to plot"): F.plot(F.Species("H")) + + +def test_plot_default_show_edges_and_empty_name(monkeypatch): + _setup_fake_pyvista(monkeypatch, off_screen=False) + plot_module = importlib.import_module("festim.plot") + monkeypatch.setattr( + plot_module, "_make_ugrid", lambda solution, pyvista_module: _MOCK_GRID + ) + + species = F.Species() + species.post_processing_solution = object() + plotter = F.plot(species) + + assert plotter.mesh_calls[0][1]["show_edges"] is False + assert plotter.text_calls == [] + assert plotter.show_called is True + + +def test_plot_off_screen_without_filename_does_not_show(monkeypatch): + _setup_fake_pyvista(monkeypatch, off_screen=True) + plot_module = importlib.import_module("festim.plot") + monkeypatch.setattr( + plot_module, "_make_ugrid", lambda solution, pyvista_module: _MOCK_GRID + ) + + species = F.Species("H") + species.post_processing_solution = object() + plotter = F.plot(species) + + assert plotter.show_called is False + assert plotter.screenshot_filename is None From a9add9446a7020b9101474922106c9141eb9c33c Mon Sep 17 00:00:00 2001 From: RemDelaporteMathurin Date: Tue, 23 Jun 2026 08:50:23 -0400 Subject: [PATCH 08/19] fixed shape --- src/festim/plot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/festim/plot.py b/src/festim/plot.py index 3d901ce6e..10dcb951a 100644 --- a/src/festim/plot.py +++ b/src/festim/plot.py @@ -68,7 +68,7 @@ def plot( ) from import_error fields = _normalize_fields(field) - shape = (1, len(fields)) if len(fields) > 1 else None + shape = (1, len(fields)) if len(fields) > 1 else (1, 1) plotter = pyvista.Plotter(shape=shape) for i, spe in enumerate(fields): From 9b8fc601e6a48eb72075622049bd46587ad4997f Mon Sep 17 00:00:00 2001 From: RemDelaporteMathurin Date: Tue, 23 Jun 2026 09:13:08 -0400 Subject: [PATCH 09/19] handle all subdomains --- src/festim/plot.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/festim/plot.py b/src/festim/plot.py index 10dcb951a..6c020817d 100644 --- a/src/festim/plot.py +++ b/src/festim/plot.py @@ -20,7 +20,12 @@ def _get_solution(field: Species, subdomain=None): f"Species {field.name} has no post_processing_solution to plot." ) return field.post_processing_solution - + else: + if field.post_processing_solution is not None: + raise ValueError( + "Problem seems to be HydrogenTransportProblem but a subdomain" + " was provided." + ) if not field.subdomain_to_post_processing_solution: raise ValueError( f"Species {field.name} has no subdomain post-processing solutions." @@ -74,6 +79,14 @@ def plot( for i, spe in enumerate(fields): if len(fields) > 1: plotter.subplot(0, i) + # if subdomain is None but the species has .subdomain_to_post_processing_solution, + # we need to plot on all subdomains + if subdomain is None: + if spe.subdomain_to_post_processing_solution: + for solution in spe.subdomain_to_post_processing_solution.values(): + u_grid = _make_ugrid(solution, pyvista) + plotter.add_mesh(u_grid, show_edges=show_edges, **kwargs) + continue solution = _get_solution(spe, subdomain=subdomain) u_grid = _make_ugrid(solution, pyvista) @@ -83,6 +96,7 @@ def plot( plotter.add_text(spe.name, font_size=DEFAULT_TITLE_FONT_SIZE) if filename is not None: + plotter.show() plotter.screenshot(str(filename)) elif not pyvista.OFF_SCREEN: plotter.show() From 4e7154c2937655a598d0c4043dd28acdc6c17914 Mon Sep 17 00:00:00 2001 From: RemDelaporteMathurin Date: Tue, 23 Jun 2026 09:17:38 -0400 Subject: [PATCH 10/19] split coloubars --- src/festim/plot.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/festim/plot.py b/src/festim/plot.py index 6c020817d..ea99d0907 100644 --- a/src/festim/plot.py +++ b/src/festim/plot.py @@ -38,13 +38,13 @@ def _get_solution(field: Species, subdomain=None): return field.subdomain_to_post_processing_solution[subdomain] -def _make_ugrid(solution, pyvista_module): +def _make_ugrid(solution, pyvista_module, name="c"): from dolfinx import plot as dolfinx_plot topology, cell_types, geometry = dolfinx_plot.vtk_mesh(solution.function_space) u_grid = pyvista_module.UnstructuredGrid(topology, cell_types, geometry) - u_grid.point_data["c"] = solution.x.array.real - u_grid.set_active_scalars("c") + u_grid.point_data[name] = solution.x.array.real + u_grid.set_active_scalars(name) return u_grid @@ -53,6 +53,7 @@ def plot( subdomain=None, filename: str | Path | None = None, show_edges: bool = False, + split_colourbars: bool = False, **kwargs, ): """ @@ -63,6 +64,7 @@ def plot( subdomain: optional volume subdomain used in mixed-domain problems. filename: optional output image path. If provided, a screenshot is saved. show_edges: whether to show mesh edges. + split_colourbars: whether to use a different colourbar for each species. **kwargs: additional arguments forwarded to ``pyvista.Plotter.add_mesh``. """ try: @@ -89,7 +91,9 @@ def plot( continue solution = _get_solution(spe, subdomain=subdomain) - u_grid = _make_ugrid(solution, pyvista) + u_grid = _make_ugrid( + solution, pyvista, name=spe.name if split_colourbars else "c" + ) plotter.add_mesh(u_grid, show_edges=show_edges, **kwargs) plotter.view_xy() if spe.name: From ee4aaaa8c1c250255c5ecfca8f579bcc9bc91a9b Mon Sep 17 00:00:00 2001 From: RemDelaporteMathurin Date: Tue, 23 Jun 2026 09:18:20 -0400 Subject: [PATCH 11/19] plotting instead of plot --- src/festim/{plot.py => plotting.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/festim/{plot.py => plotting.py} (100%) diff --git a/src/festim/plot.py b/src/festim/plotting.py similarity index 100% rename from src/festim/plot.py rename to src/festim/plotting.py From 62f7305f18138d46d1f54fac5f2b13cea9fe9e5e Mon Sep 17 00:00:00 2001 From: RemDelaporteMathurin Date: Tue, 23 Jun 2026 09:21:05 -0400 Subject: [PATCH 12/19] fixed init --- src/festim/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/festim/__init__.py b/src/festim/__init__.py index 546fd9895..722474873 100644 --- a/src/festim/__init__.py +++ b/src/festim/__init__.py @@ -72,7 +72,7 @@ from .mesh.mesh import Mesh from .mesh.mesh_1d import Mesh1D from .mesh.mesh_from_xdmf import MeshFromXDMF -from .plot import plot +from .plotting import plot from .problem import ProblemBase from .reaction import Reaction from .settings import Settings From e79006320ef73634c2d4b54606a417f9386dcb0d Mon Sep 17 00:00:00 2001 From: RemDelaporteMathurin Date: Tue, 23 Jun 2026 13:31:31 -0400 Subject: [PATCH 13/19] rewrote tests --- test/test_plot.py | 172 +++++++++++----------------------------------- 1 file changed, 42 insertions(+), 130 deletions(-) diff --git a/test/test_plot.py b/test/test_plot.py index 832e8cc8e..7c1c7372c 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -1,145 +1,80 @@ -import importlib -import sys -import types +from mpi4py import MPI +import numpy as np import pytest +from dolfinx import fem, mesh import festim as F -_MOCK_GRID = object() +pyvista = pytest.importorskip("pyvista") +pyvista.OFF_SCREEN = True -class FakePlotter: - def __init__(self, shape=None): - self.shape = shape - self.mesh_calls = [] - self.subplot_calls = [] - self.text_calls = [] - self.show_called = False - self.screenshot_filename = None +def create_mock_solution(): + test_mesh = mesh.create_unit_interval(MPI.COMM_WORLD, 10) + V = fem.functionspace(test_mesh, ("Lagrange", 1)) + u = fem.Function(V) + u.x.array[:] = np.ones_like(u.x.array) + return u - def subplot(self, row, col): - self.subplot_calls.append((row, col)) - - def add_mesh(self, grid, **kwargs): - self.mesh_calls.append((grid, kwargs)) - - def view_xy(self): - pass - - def add_text(self, text, font_size): - self.text_calls.append((text, font_size)) - - def show(self): - self.show_called = True - - def screenshot(self, filename): - self.screenshot_filename = filename - - -def _setup_fake_pyvista(monkeypatch, off_screen=False): - fake_pyvista = types.SimpleNamespace( - OFF_SCREEN=off_screen, - Plotter=FakePlotter, - UnstructuredGrid=object, - ) - monkeypatch.setitem(sys.modules, "pyvista", fake_pyvista) - return fake_pyvista - - -def test_plot_single_species(monkeypatch): - _setup_fake_pyvista(monkeypatch, off_screen=False) - plot_module = importlib.import_module("festim.plot") - monkeypatch.setattr( - plot_module, "_make_ugrid", lambda solution, pyvista_module: _MOCK_GRID - ) +def test_plot_single_species(): species = F.Species("H") - species.post_processing_solution = object() - plotter = F.plot(species, show_edges=True, opacity=0.5) + species.post_processing_solution = create_mock_solution() - assert plotter.shape is None - assert len(plotter.mesh_calls) == 1 - assert plotter.mesh_calls[0][1]["show_edges"] is True - assert plotter.mesh_calls[0][1]["opacity"] == 0.5 - assert plotter.show_called is True + plotter = F.plot(species, show_edges=True, opacity=0.5) + assert isinstance(plotter, pyvista.Plotter) + assert plotter.shape == (1, 1) -def test_plot_multiple_species_creates_subplots(monkeypatch): - _setup_fake_pyvista(monkeypatch, off_screen=False) - plot_module = importlib.import_module("festim.plot") - monkeypatch.setattr( - plot_module, "_make_ugrid", lambda solution, pyvista_module: _MOCK_GRID - ) +def test_plot_multiple_species_creates_subplots(): h = F.Species("H") d = F.Species("D") - h.post_processing_solution = object() - d.post_processing_solution = object() + h.post_processing_solution = create_mock_solution() + d.post_processing_solution = create_mock_solution() + plotter = F.plot([h, d]) assert plotter.shape == (1, 2) - assert plotter.subplot_calls == [(0, 0), (0, 1)] - assert len(plotter.mesh_calls) == 2 - -def test_plot_subdomain_uses_subdomain_solution(monkeypatch): - _setup_fake_pyvista(monkeypatch, off_screen=False) - plot_module = importlib.import_module("festim.plot") - - used_solutions = [] - - def fake_make_ugrid(solution, pyvista_module): - used_solutions.append(solution) - return _MOCK_GRID - - monkeypatch.setattr(plot_module, "_make_ugrid", fake_make_ugrid) +def test_plot_subdomain_uses_subdomain_solution(): material = F.Material(D_0=1, E_D=0) vol_1 = F.VolumeSubdomain(id=1, material=material) vol_2 = F.VolumeSubdomain(id=2, material=material) - sol_1 = object() - sol_2 = object() + sol_1 = create_mock_solution() + sol_2 = create_mock_solution() + h = F.Species("H") h.subdomain_to_post_processing_solution = {vol_1: sol_1, vol_2: sol_2} - F.plot(h, subdomain=vol_2) - assert used_solutions == [sol_2] + plotter = F.plot(h, subdomain=vol_2) + assert isinstance(plotter, pyvista.Plotter) -def test_plot_with_filename_saves_screenshot(monkeypatch, tmp_path): - _setup_fake_pyvista(monkeypatch, off_screen=True) - plot_module = importlib.import_module("festim.plot") - monkeypatch.setattr( - plot_module, "_make_ugrid", lambda solution, pyvista_module: _MOCK_GRID - ) - +def test_plot_with_filename_saves_screenshot(tmp_path): species = F.Species("H") - species.post_processing_solution = object() + species.post_processing_solution = create_mock_solution() filename = tmp_path / "out.png" - plotter = F.plot(species, filename=filename) - assert plotter.screenshot_filename == str(filename) - assert plotter.show_called is False + F.plot(species, filename=filename) + + assert filename.exists() -def test_plot_with_string_filename_saves_screenshot(monkeypatch): - _setup_fake_pyvista(monkeypatch, off_screen=True) - plot_module = importlib.import_module("festim.plot") - monkeypatch.setattr( - plot_module, "_make_ugrid", lambda solution, pyvista_module: _MOCK_GRID - ) +def test_plot_with_string_filename_saves_screenshot(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) species = F.Species("H") - species.post_processing_solution = object() - plotter = F.plot(species, filename="out.png") + species.post_processing_solution = create_mock_solution() + + F.plot(species, filename="out.png") - assert plotter.screenshot_filename == "out.png" - assert plotter.show_called is False + assert (tmp_path / "out.png").exists() -def test_plot_raises_for_invalid_field_type(monkeypatch): - _setup_fake_pyvista(monkeypatch, off_screen=False) +def test_plot_raises_for_invalid_field_type(): with pytest.raises( TypeError, match=r"field must be of type festim\.Species or a list of festim\.Species", @@ -147,38 +82,15 @@ def test_plot_raises_for_invalid_field_type(monkeypatch): F.plot("H") -def test_plot_raises_if_no_solution(monkeypatch): - _setup_fake_pyvista(monkeypatch, off_screen=False) +def test_plot_raises_if_no_solution(): with pytest.raises(ValueError, match="has no post_processing_solution to plot"): F.plot(F.Species("H")) -def test_plot_default_show_edges_and_empty_name(monkeypatch): - _setup_fake_pyvista(monkeypatch, off_screen=False) - plot_module = importlib.import_module("festim.plot") - monkeypatch.setattr( - plot_module, "_make_ugrid", lambda solution, pyvista_module: _MOCK_GRID - ) - +def test_plot_default_show_edges_and_empty_name(): species = F.Species() - species.post_processing_solution = object() - plotter = F.plot(species) - - assert plotter.mesh_calls[0][1]["show_edges"] is False - assert plotter.text_calls == [] - assert plotter.show_called is True + species.post_processing_solution = create_mock_solution() - -def test_plot_off_screen_without_filename_does_not_show(monkeypatch): - _setup_fake_pyvista(monkeypatch, off_screen=True) - plot_module = importlib.import_module("festim.plot") - monkeypatch.setattr( - plot_module, "_make_ugrid", lambda solution, pyvista_module: _MOCK_GRID - ) - - species = F.Species("H") - species.post_processing_solution = object() plotter = F.plot(species) - assert plotter.show_called is False - assert plotter.screenshot_filename is None + assert isinstance(plotter, pyvista.Plotter) From 89b1c2995137fb36f7fc46d5cc87046ff14268a6 Mon Sep 17 00:00:00 2001 From: RemDelaporteMathurin Date: Wed, 24 Jun 2026 08:17:49 -0400 Subject: [PATCH 14/19] added pyvista to test --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 50046b102..79d38c3ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ classifiers = [ ] [project.optional-dependencies] -test = ["pytest >= 5.4.3", "pytest-cov", "sympy", "ipyparallel"] +test = ["pytest >= 5.4.3", "pytest-cov", "sympy", "ipyparallel", "pyvista"] lint = ["ruff", "mypy"] docs = ["sphinx", "sphinx-book-theme", "sphinx-design", "matplotlib"] From 86e3d200c81394a19b7b2a6217d87dce61e83cb0 Mon Sep 17 00:00:00 2001 From: RemDelaporteMathurin Date: Wed, 24 Jun 2026 08:23:14 -0400 Subject: [PATCH 15/19] install via conda --- .github/workflows/ci_conda.yml | 2 +- .github/workflows/ci_docker.yml | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci_conda.yml b/.github/workflows/ci_conda.yml index 3fd1e499b..34ab51e2b 100644 --- a/.github/workflows/ci_conda.yml +++ b/.github/workflows/ci_conda.yml @@ -26,7 +26,7 @@ jobs: - name: Create Conda environment shell: bash -l {0} run: | - conda install -c conda-forge python pip fenics-dolfinx=${{ matrix.dolfinx }} scifem adios4dolfinx + conda install -c conda-forge python pip fenics-dolfinx=${{ matrix.dolfinx }} scifem adios4dolfinx pyvista - name: Install local package and dependencies shell: bash -l {0} diff --git a/.github/workflows/ci_docker.yml b/.github/workflows/ci_docker.yml index c9b4ad910..a8cd8364f 100644 --- a/.github/workflows/ci_docker.yml +++ b/.github/workflows/ci_docker.yml @@ -20,7 +20,7 @@ jobs: - name: Install local package and dependencies run: | - python -m pip install scipy # needed in scifem, can be removed when scifem adds it to their dependencies + python -m pip install scipy pyvista # scipy needed in scifem, can be removed when scifem adds it to their dependencies python -m pip install .[test] - name: Overload adios4dolfinx diff --git a/pyproject.toml b/pyproject.toml index 79d38c3ed..50046b102 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ classifiers = [ ] [project.optional-dependencies] -test = ["pytest >= 5.4.3", "pytest-cov", "sympy", "ipyparallel", "pyvista"] +test = ["pytest >= 5.4.3", "pytest-cov", "sympy", "ipyparallel"] lint = ["ruff", "mypy"] docs = ["sphinx", "sphinx-book-theme", "sphinx-design", "matplotlib"] From d7f28b655e522ef123a62b1093ae246acb031ece Mon Sep 17 00:00:00 2001 From: RemDelaporteMathurin Date: Wed, 24 Jun 2026 08:32:57 -0400 Subject: [PATCH 16/19] added plotter close --- test/test_plot.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/test_plot.py b/test/test_plot.py index 7c1c7372c..d898e038e 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -26,6 +26,7 @@ def test_plot_single_species(): assert isinstance(plotter, pyvista.Plotter) assert plotter.shape == (1, 1) + plotter.close() def test_plot_multiple_species_creates_subplots(): @@ -37,6 +38,7 @@ def test_plot_multiple_species_creates_subplots(): plotter = F.plot([h, d]) assert plotter.shape == (1, 2) + plotter.close() def test_plot_subdomain_uses_subdomain_solution(): @@ -51,6 +53,7 @@ def test_plot_subdomain_uses_subdomain_solution(): plotter = F.plot(h, subdomain=vol_2) assert isinstance(plotter, pyvista.Plotter) + plotter.close() def test_plot_with_filename_saves_screenshot(tmp_path): @@ -94,3 +97,4 @@ def test_plot_default_show_edges_and_empty_name(): plotter = F.plot(species) assert isinstance(plotter, pyvista.Plotter) + plotter.close() From 8e62a7bfcf6474089b9f9df3f0eebb06d13169a9 Mon Sep 17 00:00:00 2001 From: RemDelaporteMathurin Date: Wed, 24 Jun 2026 08:44:33 -0400 Subject: [PATCH 17/19] more closes --- test/test_plot.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/test_plot.py b/test/test_plot.py index d898e038e..7a1c80404 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -61,8 +61,8 @@ def test_plot_with_filename_saves_screenshot(tmp_path): species.post_processing_solution = create_mock_solution() filename = tmp_path / "out.png" - F.plot(species, filename=filename) - + plotter = F.plot(species, filename=filename) + plotter.close() assert filename.exists() @@ -72,7 +72,8 @@ def test_plot_with_string_filename_saves_screenshot(tmp_path, monkeypatch): species = F.Species("H") species.post_processing_solution = create_mock_solution() - F.plot(species, filename="out.png") + plotter = F.plot(species, filename="out.png") + plotter.close() assert (tmp_path / "out.png").exists() From 6d011f9501b8352a8287b1a6eb5ca4bebe9a2fc9 Mon Sep 17 00:00:00 2001 From: RemDelaporteMathurin Date: Wed, 24 Jun 2026 08:58:20 -0400 Subject: [PATCH 18/19] added headless display action --- .github/workflows/ci_conda.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci_conda.yml b/.github/workflows/ci_conda.yml index 34ab51e2b..56c09bb70 100644 --- a/.github/workflows/ci_conda.yml +++ b/.github/workflows/ci_conda.yml @@ -15,6 +15,8 @@ jobs: - name: Checkout code uses: actions/checkout@v6 + - uses: pyvista/setup-headless-display-action@v3 + - name: Set up Conda uses: conda-incubator/setup-miniconda@v4 with: From a8d1069710d9e2a131576cacd739962e90e10cf7 Mon Sep 17 00:00:00 2001 From: RemDelaporteMathurin Date: Wed, 24 Jun 2026 09:07:00 -0400 Subject: [PATCH 19/19] increase coverage --- test/test_plot.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/test_plot.py b/test/test_plot.py index 7a1c80404..f0130e315 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -99,3 +99,18 @@ def test_plot_default_show_edges_and_empty_name(): assert isinstance(plotter, pyvista.Plotter) plotter.close() + + +def test_plot_default_with_several_subdomains(): + material = F.Material(D_0=1, E_D=0) + vol_1 = F.VolumeSubdomain(id=1, material=material) + vol_2 = F.VolumeSubdomain(id=2, material=material) + sol_1 = create_mock_solution() + sol_2 = create_mock_solution() + + h = F.Species("H") + h.subdomain_to_post_processing_solution = {vol_1: sol_1, vol_2: sol_2} + + plotter = F.plot(h) + assert isinstance(plotter, pyvista.Plotter) + plotter.close()