From 829373552ae60b98f9107bc78786f709bef3597b Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:22:46 +0200 Subject: [PATCH 1/6] MAINT: add TypeAlias for CollapseMode in _labels.py --- src/qrules/io/_labels.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/qrules/io/_labels.py b/src/qrules/io/_labels.py index e79f4c82..638ffb63 100644 --- a/src/qrules/io/_labels.py +++ b/src/qrules/io/_labels.py @@ -5,7 +5,7 @@ from fractions import Fraction from functools import singledispatch from inspect import isfunction -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeAlias import attrs @@ -29,6 +29,7 @@ _LOGGER = logging.getLogger(__name__) +CollapseMode: TypeAlias = Literal["spin", "topology"] RenderedGraph = ProblemSet | QNProblemSet | Topology | Transition RenderPair = tuple[Topology, RenderedGraph] RenderInput = RenderedGraph | RenderPair From 15e62c819363d944928a9f7e7dc21c40f860e8cf Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:26:47 +0200 Subject: [PATCH 2/6] MAINT: refactor transition handling to use CollapseMode for collapsing options --- src/qrules/io/__init__.py | 38 +++++++++++++++++--------------------- src/qrules/io/_dot.py | 32 +++++++------------------------- src/qrules/io/_labels.py | 21 +++++++++++++++++++++ src/qrules/io/_mermaid.py | 18 ++++++------------ 4 files changed, 51 insertions(+), 58 deletions(-) diff --git a/src/qrules/io/__init__.py b/src/qrules/io/__init__.py index 07a2018a..f428e745 100644 --- a/src/qrules/io/__init__.py +++ b/src/qrules/io/__init__.py @@ -14,10 +14,12 @@ import attrs import yaml -from qrules.io import _dict, _dot, _mermaid +from qrules.io import _dict, _dot, _labels, _mermaid from qrules.particle import Particle, ParticleCollection from qrules.topology import Topology +CollapseMode = _labels.CollapseMode + if TYPE_CHECKING: from _typeshed import StrPath @@ -62,8 +64,7 @@ def asdot( render_final_state_id: bool = True, render_resonance_id: bool = False, render_initial_state_id: bool = False, - strip_spin: bool = False, - collapse_graphs: bool = False, + collapse: CollapseMode | None = None, edge_style: dict[str, Any] | None = None, node_style: dict[str, Any] | None = None, **figure_style: Any, @@ -76,12 +77,11 @@ def asdot( Args: instance: the input `object` that is to be rendered as DOT (graphviz) language. - strip_spin: Normally, each `.MutableTransition` has a `.Particle` with a spin - projection on its edges. This option hides the projections, leaving only - `.Particle` names on edges. - - collapse_graphs: Group all transitions by equivalent kinematic topology - and combine all allowed particles on each edge. + collapse: Optionally combine transitions. With ``"spin"``, transitions that + differ only in their spin projections are combined. With ``"topology"``, + transitions with equivalent kinematic topologies are grouped and all + allowed particles on each edge are collected. Node properties cannot be + rendered when collapsing by topology. render_node: Whether or not to render node ID (in the case of a `.Topology`) and/or node properties (in the case of a `.MutableTransition`). Meaning of @@ -113,8 +113,7 @@ def asdot( render_final_state_id=render_final_state_id, render_resonance_id=render_resonance_id, render_initial_state_id=render_initial_state_id, - strip_spin=strip_spin, - collapse_graphs=collapse_graphs, + collapse=collapse, figure_style=figure_style, edge_style=edge_style, node_style=node_style, @@ -129,8 +128,7 @@ def asmermaid( render_final_state_id: bool = True, render_resonance_id: bool = False, render_initial_state_id: bool = False, - strip_spin: bool = False, - collapse_graphs: bool = False, + collapse: CollapseMode | None = None, figure_style: dict[str, Any] | None = None, edge_style: dict[str, Any] | None = None, node_style: dict[str, Any] | None = None, @@ -146,12 +144,11 @@ def asmermaid( instance: the input `object` that is to be rendered as Mermaid flowchart source. - strip_spin: Normally, each `.MutableTransition` has a `.Particle` with a spin - projection on its edges. This option hides the projections, leaving only - `.Particle` names on edges. - - collapse_graphs: Group all transitions by equivalent kinematic topology - and combine all allowed particles on each edge. + collapse: Optionally combine transitions. With ``"spin"``, transitions that + differ only in their spin projections are combined. With ``"topology"``, + transitions with equivalent kinematic topologies are grouped and all + allowed particles on each edge are collected. Node properties cannot be + rendered when collapsing by topology. render_node: Whether or not to render node ID (in the case of a `.Topology`) and/or node properties (in the case of a `.MutableTransition`). Meaning of @@ -187,8 +184,7 @@ def asmermaid( render_final_state_id=render_final_state_id, render_resonance_id=render_resonance_id, render_initial_state_id=render_initial_state_id, - strip_spin=strip_spin, - collapse_graphs=collapse_graphs, + collapse=collapse, figure_style=figure_style, edge_style=edge_style, node_style=node_style, diff --git a/src/qrules/io/_dot.py b/src/qrules/io/_dot.py index 3a063351..6a5dc1b4 100644 --- a/src/qrules/io/_dot.py +++ b/src/qrules/io/_dot.py @@ -10,7 +10,7 @@ from collections import abc from typing import TYPE_CHECKING, Any -from attrs import Attribute, define, field +from attrs import define, field from attrs.converters import default_if_none from qrules.io import _labels @@ -24,19 +24,6 @@ _LOGGER = logging.getLogger(__name__) -def _check_booleans( - instance: GraphvizPrinter, - _attribute: Attribute, - _value: bool, -) -> None: - if instance.strip_spin and instance.collapse_graphs: - msg = "Cannot both strip spin and collapse graphs" - raise ValueError(msg) - if instance.collapse_graphs and instance.render_node: - msg = "Collapsed graphs cannot be rendered with node properties" - raise ValueError(msg) - - def _create_default_figure_style(style: dict[str, Any] | None) -> dict[str, Any]: figure_style = {"bgcolor": None} if style is None: @@ -51,8 +38,7 @@ class GraphvizPrinter: render_final_state_id: bool = True render_resonance_id: bool = False render_initial_state_id: bool = False - strip_spin: bool = False - collapse_graphs: bool = False + collapse: _labels.CollapseMode | None = None figure_style: dict[str, Any] = field( converter=_create_default_figure_style, default=None @@ -97,15 +83,11 @@ def _render(self, obj: Any) -> list[str]: raise NotImplementedError(msg) def _render_multiple_transitions(self, obj: Iterable) -> list[str]: - if self.collapse_graphs: - transitions: list = _labels.collapse_graphs(obj) - elif self.strip_spin: - if self.render_node: - transitions = sorted({_labels.strip_projections(t) for t in obj}) - else: - transitions = _labels.get_particle_graphs(obj) - else: - transitions = list(obj) + transitions = _labels.prepare_transitions( + obj, + collapse=self.collapse, + render_node=self.render_node, + ) lines = [] for i, graph in enumerate(reversed(list(transitions))): lines += self._render_transition(graph, prefix=f"T{i}_") diff --git a/src/qrules/io/_labels.py b/src/qrules/io/_labels.py index 638ffb63..96a3b4a2 100644 --- a/src/qrules/io/_labels.py +++ b/src/qrules/io/_labels.py @@ -547,6 +547,27 @@ def get_particle_graphs( ) +def prepare_transitions( + transitions: Iterable[Any], + *, + collapse: CollapseMode | None, + render_node: bool | None, +) -> list[Any]: + if collapse is None: + return list(transitions) + if collapse == "spin": + if render_node: + return sorted({strip_projections(t) for t in transitions}) + return get_particle_graphs(transitions) + if collapse == "topology": + if render_node: + msg = "Transitions collapsed by topology cannot render node properties" + raise ValueError(msg) + return collapse_graphs(transitions) + msg = f"Unknown collapse mode {collapse!r}; expected None, 'spin', or 'topology'" + raise ValueError(msg) + + def strip_projections( graph: Transition[Any, InteractionProperties], ) -> FrozenTransition[Particle, InteractionProperties]: diff --git a/src/qrules/io/_mermaid.py b/src/qrules/io/_mermaid.py index 6b6f7d24..c3befcf2 100644 --- a/src/qrules/io/_mermaid.py +++ b/src/qrules/io/_mermaid.py @@ -78,8 +78,7 @@ class MermaidPrinter: render_final_state_id: bool = True render_resonance_id: bool = False render_initial_state_id: bool = False - strip_spin: bool = False - collapse_graphs: bool = False + collapse: _labels.CollapseMode | None = None figure_style: dict[str, Any] = attrs.field(converter=_to_style_dict, default=None) edge_style: dict[str, Any] = attrs.field(converter=_to_style_dict, default=None) node_style: dict[str, Any] = attrs.field(converter=_to_style_dict, default=None) @@ -104,16 +103,11 @@ def _render(self, obj: Any) -> list[str]: raise NotImplementedError(msg) def _render_multiple_transitions(self, obj: Iterable) -> list[str]: - transitions: Iterable[Transition[Any, Any]] - if self.collapse_graphs: - transitions = _labels.collapse_graphs(obj) - elif self.strip_spin: - if self.render_node: - transitions = sorted({_labels.strip_projections(t) for t in obj}) - else: - transitions = _labels.get_particle_graphs(obj) - else: - transitions = list(obj) + transitions = _labels.prepare_transitions( + obj, + collapse=self.collapse, + render_node=self.render_node, + ) lines: list[str] = [] for i, graph in enumerate(reversed(list(transitions))): From e103b5bca551c1ed6267e3cb0ba3aaf54ad7da22 Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:29:48 +0200 Subject: [PATCH 3/6] MAINT: update asdot and asmermaid functions to use collapse parameter instead of strip_spin and collapse_graphs --- tests/unit/io/test_dot.py | 8 +++---- tests/unit/io/test_labels.py | 44 +++++++++++++++++++++++++++++++++++ tests/unit/io/test_mermaid.py | 10 ++++---- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/tests/unit/io/test_dot.py b/tests/unit/io/test_dot.py index c45253eb..3bd8916b 100644 --- a/tests/unit/io/test_dot.py +++ b/tests/unit/io/test_dot.py @@ -19,9 +19,9 @@ def test_asdot(reaction: ReactionInfo): assert is_valid_dot(src) src = io.asdot(reaction) assert is_valid_dot(src) - src = io.asdot(reaction, strip_spin=True) + src = io.asdot(reaction, collapse="spin") assert is_valid_dot(src) - src = io.asdot(reaction, collapse_graphs=True) + src = io.asdot(reaction, collapse="topology") assert is_valid_dot(src) @@ -192,10 +192,10 @@ def test_write_graph_list(self, output_dir: str, reaction: ReactionInfo): src = stream.read() assert is_valid_dot(src) - def test_write_strip_spin(self, output_dir: str, reaction: ReactionInfo): + def test_write_collapse_spin(self, output_dir: str, reaction: ReactionInfo): output_file = output_dir + "test_particle_graphs.gv" io.write( - instance=io.asdot(reaction, strip_spin=True), + instance=io.asdot(reaction, collapse="spin"), filename=output_file, ) with open(output_file) as stream: diff --git a/tests/unit/io/test_labels.py b/tests/unit/io/test_labels.py index 09a96c17..c00b9812 100644 --- a/tests/unit/io/test_labels.py +++ b/tests/unit/io/test_labels.py @@ -3,6 +3,7 @@ from textwrap import dedent import attrs +import pytest import qrules from qrules.io._labels import ( @@ -11,6 +12,7 @@ collapse_graphs, create_edge_label, get_particle_graphs, + prepare_transitions, strip_projections, ) from qrules.particle import Particle, ParticleCollection @@ -305,6 +307,48 @@ def test_collapse_graphs( assert intermediate_states == f_resonances +def test_prepare_transitions(reaction: ReactionInfo): + transitions = reaction.transitions + assert prepare_transitions( + transitions, + collapse=None, + render_node=False, + ) == list(transitions) + assert prepare_transitions( + transitions, + collapse="spin", + render_node=False, + ) == get_particle_graphs(transitions) + assert prepare_transitions( + transitions, + collapse="spin", + render_node=True, + ) == sorted({strip_projections(t) for t in transitions}) + assert prepare_transitions( + transitions, + collapse="topology", + render_node=False, + ) == collapse_graphs(transitions) + + +def test_prepare_transitions_rejects_node_properties(reaction: ReactionInfo): + with pytest.raises(ValueError, match="cannot render node properties"): + prepare_transitions( + reaction.transitions, + collapse="topology", + render_node=True, + ) + + +def test_prepare_transitions_rejects_unknown_mode(reaction: ReactionInfo): + with pytest.raises(ValueError, match="Unknown collapse mode 'invalid'"): + prepare_transitions( + reaction.transitions, + collapse="invalid", # ty: ignore[invalid-argument-type] + render_node=False, + ) + + def test_get_particle_graphs( reaction: ReactionInfo, particle_database: ParticleCollection ): diff --git a/tests/unit/io/test_mermaid.py b/tests/unit/io/test_mermaid.py index 02037ca7..f339eac7 100644 --- a/tests/unit/io/test_mermaid.py +++ b/tests/unit/io/test_mermaid.py @@ -85,13 +85,13 @@ def test_asmermaid_latex_reaction(reaction: ReactionInfo): def test_asmermaid_latex_collapsed_graph(reaction: ReactionInfo): - src = io.asmermaid(reaction, collapse_graphs=True, latex=True) + src = io.asmermaid(reaction, collapse="topology", latex=True) assert R"$$\begin{gathered} f_{0}(980)" in src assert R"\\\ f_{0}(1500) \end{gathered}$$" in src -def test_asmermaid_latex_strip_spin(reaction: ReactionInfo): - src = io.asmermaid(reaction, strip_spin=True, latex=True) +def test_asmermaid_latex_collapse_spin(reaction: ReactionInfo): + src = io.asmermaid(reaction, collapse="spin", latex=True) assert R"J/\psi(1S)$$" in src assert R"\gamma$$" in src assert R"\left[" not in src @@ -132,10 +132,10 @@ def test_asmermaid_reaction(reaction: ReactionInfo): src = io.asmermaid(reaction, latex=False) assert src.startswith("flowchart LR\n") assert " --- " in src - src = io.asmermaid(reaction, strip_spin=True, latex=False) + src = io.asmermaid(reaction, collapse="spin", latex=False) assert src.startswith("flowchart LR\n") assert " --- " in src - src = io.asmermaid(reaction, collapse_graphs=True, latex=False) + src = io.asmermaid(reaction, collapse="topology", latex=False) assert src.startswith("flowchart LR\n") assert " --- " in src From a168b5d5c999d28d915f0e5c67ce13d3be7f94f7 Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:30:31 +0200 Subject: [PATCH 4/6] MAINT: update GraphvizPrinter to use kw_only for better parameter handling --- src/qrules/io/_dot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qrules/io/_dot.py b/src/qrules/io/_dot.py index 6a5dc1b4..92256ad8 100644 --- a/src/qrules/io/_dot.py +++ b/src/qrules/io/_dot.py @@ -32,7 +32,7 @@ def _create_default_figure_style(style: dict[str, Any] | None) -> dict[str, Any] return figure_style -@define(on_setattr=_check_booleans) +@define(kw_only=True) class GraphvizPrinter: render_node: bool | None = None render_final_state_id: bool = True From b4ff3d7982c3cb290917766f61320d9f080248d3 Mon Sep 17 00:00:00 2001 From: Fabian Steube <206550910+Flomber@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:34:54 +0200 Subject: [PATCH 5/6] MAINT: update all notebooks to use collapse rather than using strip_spin and collapse_graph args --- docs/usage.ipynb | 2 +- docs/usage/custom-topology.ipynb | 6 +++--- docs/usage/ls-coupling.ipynb | 2 +- docs/usage/reaction.ipynb | 4 ++-- docs/usage/visualize.ipynb | 14 +++++++------- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/usage.ipynb b/docs/usage.ipynb index bebf67de..b4cc7853 100644 --- a/docs/usage.ipynb +++ b/docs/usage.ipynb @@ -64,7 +64,7 @@ "source": [ "from IPython.display import Markdown\n", "\n", - "source = qrules.io.asmermaid(reaction, collapse_graphs=True, markdown=True)\n", + "source = qrules.io.asmermaid(reaction, collapse=\"topology\", markdown=True)\n", "Markdown(source)" ] }, diff --git a/docs/usage/custom-topology.ipynb b/docs/usage/custom-topology.ipynb index ba27e2c6..1d27912b 100644 --- a/docs/usage/custom-topology.ipynb +++ b/docs/usage/custom-topology.ipynb @@ -153,7 +153,7 @@ }, "outputs": [], "source": [ - "source = qrules.io.asmermaid(reaction_kk, collapse_graphs=True, markdown=True)\n", + "source = qrules.io.asmermaid(reaction_kk, collapse=\"topology\", markdown=True)\n", "Markdown(source)" ] }, @@ -192,7 +192,7 @@ }, "outputs": [], "source": [ - "source = qrules.io.asmermaid(reaction_ep, collapse_graphs=True, markdown=True)\n", + "source = qrules.io.asmermaid(reaction_ep, collapse=\"topology\", markdown=True)\n", "Markdown(source)" ] }, @@ -231,7 +231,7 @@ }, "outputs": [], "source": [ - "source = qrules.io.asmermaid(reaction_ep_no_mass, collapse_graphs=True, markdown=True)\n", + "source = qrules.io.asmermaid(reaction_ep_no_mass, collapse=\"topology\", markdown=True)\n", "Markdown(source)" ] } diff --git a/docs/usage/ls-coupling.ipynb b/docs/usage/ls-coupling.ipynb index fe1cb927..e1b4e7e8 100644 --- a/docs/usage/ls-coupling.ipynb +++ b/docs/usage/ls-coupling.ipynb @@ -277,7 +277,7 @@ " allowed_interaction_types=\"strong\",\n", " max_angular_momentum=3,\n", ")\n", - "source = qrules.io.asmermaid(reaction, markdown=True, render_node=True, strip_spin=True)\n", + "source = qrules.io.asmermaid(reaction, collapse=\"spin\", markdown=True, render_node=True)\n", "Markdown(source)" ] } diff --git a/docs/usage/reaction.ipynb b/docs/usage/reaction.ipynb index ebf2a254..d61e9365 100644 --- a/docs/usage/reaction.ipynb +++ b/docs/usage/reaction.ipynb @@ -379,7 +379,7 @@ }, "outputs": [], "source": [ - "source = io.asmermaid(lc2pkpi_reaction, collapse_graphs=True, markdown=True)\n", + "source = io.asmermaid(lc2pkpi_reaction, collapse=\"topology\", markdown=True)\n", "Markdown(source)" ] }, @@ -480,7 +480,7 @@ }, "outputs": [], "source": [ - "source = io.asmermaid(reaction, collapse_graphs=True, markdown=True, render_node=False)\n", + "source = io.asmermaid(reaction, collapse=\"topology\", markdown=True, render_node=False)\n", "Markdown(source)" ] }, diff --git a/docs/usage/visualize.ipynb b/docs/usage/visualize.ipynb index 6e14c30e..371b536f 100644 --- a/docs/usage/visualize.ipynb +++ b/docs/usage/visualize.ipynb @@ -268,7 +268,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Since this list of all possible spin projections {attr}`~.ReactionInfo.transitions` is rather long, it is often useful to use `strip_spin=True` or `collapse_graphs=True` to bundle comparable graphs. First, {code}`strip_spin=True` allows one collapse (ignore) the spin projections (we again show a selection only):" + "Since this list of all possible spin projections {attr}`~.ReactionInfo.transitions` is rather long, it is often useful to collapse comparable graphs. First, {code}`collapse=\"spin\"` allows one to collapse (ignore) the spin projections (we again show a selection only):" ] }, { @@ -278,7 +278,7 @@ "outputs": [], "source": [ "first_transitions = reaction.transitions[:3]\n", - "source = qrules.io.asmermaid(first_transitions, markdown=True, strip_spin=True)\n", + "source = qrules.io.asmermaid(first_transitions, collapse=\"spin\", markdown=True)\n", "Markdown(source)" ] }, @@ -299,7 +299,7 @@ " first_transitions,\n", " markdown=True,\n", " render_node=True,\n", - " strip_spin=True,\n", + " collapse=\"spin\",\n", ")\n", "Markdown(source)" ] @@ -317,7 +317,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "If that list is still too much, there is {code}`collapse_graphs=True`, which bundles all graphs with the same final state groupings:" + "If that list is still too much, {code}`collapse=\"topology\"` bundles all graphs with the same final state groupings:" ] }, { @@ -327,7 +327,7 @@ "outputs": [], "source": [ "source = qrules.io.asmermaid(\n", - " reaction, collapse_graphs=True, markdown=True, render_node=False\n", + " reaction, collapse=\"topology\", markdown=True, render_node=False\n", ")\n", "Markdown(source)" ] @@ -345,7 +345,7 @@ "metadata": {}, "outputs": [], "source": [ - "dot = qrules.io.asdot(reaction, collapse_graphs=True, render_node=False)\n", + "dot = qrules.io.asdot(reaction, collapse=\"topology\", render_node=False)\n", "graphviz.Source(dot)" ] }, @@ -486,7 +486,7 @@ " )\n", " for t in reaction.transitions\n", "})\n", - "source = qrules.io.asmermaid(jpc_ig_transitions, collapse_graphs=True, markdown=True)\n", + "source = qrules.io.asmermaid(jpc_ig_transitions, collapse=\"topology\", markdown=True)\n", "Markdown(source)" ] }, From 87547f1cbfa09a5806e336da204a0f37e3ca845a Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:13:27 +0200 Subject: [PATCH 6/6] MAINT: minor formatting improvements --- src/qrules/io/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/qrules/io/__init__.py b/src/qrules/io/__init__.py index f428e745..3f030e0d 100644 --- a/src/qrules/io/__init__.py +++ b/src/qrules/io/__init__.py @@ -146,9 +146,9 @@ def asmermaid( collapse: Optionally combine transitions. With ``"spin"``, transitions that differ only in their spin projections are combined. With ``"topology"``, - transitions with equivalent kinematic topologies are grouped and all - allowed particles on each edge are collected. Node properties cannot be - rendered when collapsing by topology. + transitions with equivalent kinematic topologies are grouped and all allowed + particles on each edge are collected. Node properties cannot be rendered + when collapsing by topology. render_node: Whether or not to render node ID (in the case of a `.Topology`) and/or node properties (in the case of a `.MutableTransition`). Meaning of