Skip to content

Commit 5f924d8

Browse files
hf-kkleinKonstantin
andauthored
feat: introduce TransitionNodes (only 1 subsequent step, no decision) (#382)
Co-authored-by: Konstantin <[email protected]>
1 parent 37db603 commit 5f924d8

File tree

7 files changed

+338
-17
lines changed

7 files changed

+338
-17
lines changed

src/rebdhuhn/graph_conversion.py

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
This module contains logic to convert EbdTable data to EbdGraph data.
33
"""
44

5-
from typing import Dict, List, Optional
5+
from typing import Dict, List, Literal, Optional, overload
66

77
from networkx import DiGraph # type:ignore[import-untyped]
88

@@ -22,7 +22,7 @@
2222
ToNoEdge,
2323
ToYesEdge,
2424
)
25-
from rebdhuhn.models.ebd_graph import EmptyNode
25+
from rebdhuhn.models.ebd_graph import EmptyNode, TransitionEdge, TransitionNode
2626
from rebdhuhn.models.errors import (
2727
EbdCrossReferenceNotSupportedError,
2828
EndeInWrongColumnError,
@@ -69,17 +69,29 @@ def _convert_sub_row_to_outcome_node(sub_row: EbdTableSubRow) -> Optional[Outcom
6969
return None
7070

7171

72-
def _convert_row_to_decision_node(row: EbdTableRow) -> DecisionNode:
72+
def _convert_row_to_decision_or_transition_node(row: EbdTableRow) -> DecisionNode | TransitionNode:
7373
"""
7474
converts a row into a decision node
7575
"""
76+
if len(row.sub_rows) == 1 and row.sub_rows[0].check_result.result is None:
77+
# this is a transition node
78+
return TransitionNode(step_number=row.step_number, question=row.description, note=row.sub_rows[0].note)
7679
return DecisionNode(step_number=row.step_number, question=row.description)
7780

7881

79-
def _yes_no_transition_edge(decision: Optional[bool], source: DecisionNode, target: EbdGraphNode) -> EbdGraphEdge:
80-
if decision is None:
81-
# happens in another PR
82-
raise NotImplementedError("None not supported yet; https://github.com/Hochfrequenz/rebdhuhn/issues/380")
82+
@overload
83+
def _yes_no_transition_edge(
84+
decision: Literal[None], source: TransitionNode, target: EbdGraphNode
85+
) -> TransitionEdge: ...
86+
@overload
87+
def _yes_no_transition_edge(decision: bool, source: DecisionNode, target: EbdGraphNode) -> EbdGraphEdge: ...
88+
def _yes_no_transition_edge(
89+
decision: Optional[bool], source: DecisionNode | TransitionNode, target: EbdGraphNode
90+
) -> EbdGraphEdge:
91+
if decision is None and isinstance(source, TransitionNode):
92+
return TransitionEdge(source=source, target=target, note=None)
93+
assert not isinstance(source, TransitionNode), "Iff the decision is None, source must be a TransitionNode"
94+
assert isinstance(source, DecisionNode)
8395
if decision is True:
8496
return ToYesEdge(source=source, target=target, note=None)
8597
if decision is False:
@@ -95,8 +107,10 @@ def get_all_nodes(table: EbdTable) -> List[EbdGraphNode]:
95107
result: List[EbdGraphNode] = [StartNode()]
96108
contains_ende = False
97109
for row in table.rows:
98-
decision_node = _convert_row_to_decision_node(row)
99-
result.append(decision_node)
110+
decision_or_transition_node = _convert_row_to_decision_or_transition_node(row)
111+
result.append(decision_or_transition_node)
112+
if isinstance(decision_or_transition_node, TransitionNode):
113+
continue
100114
for sub_row in row.sub_rows:
101115
outcome_node = _convert_sub_row_to_outcome_node(sub_row)
102116
if outcome_node is not None:
@@ -134,12 +148,24 @@ def get_all_edges(table: EbdTable) -> List[EbdGraphEdge]:
134148
outcome_nodes_duplicates: dict[str, OutcomeNode] = {} # map to check for duplicate outcome nodes
135149

136150
for row in table.rows:
137-
decision_node = _convert_row_to_decision_node(row)
151+
row_node = _convert_row_to_decision_or_transition_node(row)
152+
if isinstance(row_node, TransitionNode):
153+
assert row.sub_rows[0].check_result.subsequent_step_number is not None
154+
result.append(
155+
TransitionEdge(
156+
source=row_node,
157+
target=nodes[row.sub_rows[0].check_result.subsequent_step_number],
158+
note=row_node.note,
159+
)
160+
)
161+
continue
162+
assert isinstance(row_node, DecisionNode)
138163
for sub_row in row.sub_rows:
164+
assert isinstance(sub_row.check_result.result, bool)
139165
if sub_row.check_result.subsequent_step_number is not None and not _is_ende_with_no_code_but_note(sub_row):
140166
edge = _yes_no_transition_edge(
141167
sub_row.check_result.result,
142-
source=decision_node,
168+
source=row_node,
143169
target=nodes[sub_row.check_result.subsequent_step_number],
144170
)
145171
else:
@@ -149,8 +175,8 @@ def get_all_edges(table: EbdTable) -> List[EbdGraphEdge]:
149175
if all(sr.result_code is None for sr in row.sub_rows) and any(
150176
sr.note is not None and sr.note.startswith("EBD ") for sr in row.sub_rows
151177
):
152-
raise EbdCrossReferenceNotSupportedError(row=row, decision_node=decision_node)
153-
raise OutcomeNodeCreationError(decision_node=decision_node, sub_row=sub_row)
178+
raise EbdCrossReferenceNotSupportedError(row=row, decision_node=row_node)
179+
raise OutcomeNodeCreationError(decision_node=row_node, sub_row=sub_row)
154180

155181
# check for ambiguous outcome nodes, i.e. A** with different notes
156182
is_ambiguous_outcome_node = (
@@ -167,7 +193,7 @@ def get_all_edges(table: EbdTable) -> List[EbdGraphEdge]:
167193

168194
edge = _yes_no_transition_edge(
169195
sub_row.check_result.result,
170-
source=decision_node,
196+
source=row_node,
171197
target=nodes[outcome_node.get_key()],
172198
)
173199
result.append(edge)

src/rebdhuhn/graphviz.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from rebdhuhn.graph_utils import _mark_last_common_ancestors
1111
from rebdhuhn.kroki import DotToSvgConverter
1212
from rebdhuhn.models import DecisionNode, EbdGraph, EbdGraphEdge, EndNode, OutcomeNode, StartNode, ToNoEdge, ToYesEdge
13-
from rebdhuhn.models.ebd_graph import EmptyNode
13+
from rebdhuhn.models.ebd_graph import EmptyNode, TransitionNode
1414
from rebdhuhn.utils import add_line_breaks
1515

1616
ADD_INDENT = " " #: This is just for style purposes to make the plantuml files human-readable.
@@ -104,6 +104,25 @@ def _convert_decision_node_to_dot(ebd_graph: EbdGraph, node: str, indent: str) -
104104
)
105105

106106

107+
def _convert_transition_node_to_dot(ebd_graph: EbdGraph, node: str, indent: str) -> str:
108+
"""
109+
Convert a TransitionNode to dot code
110+
"""
111+
formatted_label = (
112+
f'<B>{ebd_graph.graph.nodes[node]["node"].step_number}: </B>'
113+
f'{_format_label(ebd_graph.graph.nodes[node]["node"].question)}'
114+
f'<BR align="left"/>'
115+
)
116+
if ebd_graph.graph.nodes[node]["node"].note:
117+
formatted_label += (
118+
f"<FONT>" f'{_format_label(ebd_graph.graph.nodes[node]["node"].note)}<BR align="left"/>' f"</FONT>"
119+
)
120+
return (
121+
f'{indent}"{node}" [margin="0.2,0.12", shape=box, style="filled,rounded", penwidth=0.0, fillcolor="#c2cee9", '
122+
f'label=<{formatted_label}>, fontname="Roboto, sans-serif"];'
123+
)
124+
125+
107126
def _convert_node_to_dot(ebd_graph: EbdGraph, node: str, indent: str) -> str:
108127
"""
109128
A shorthand to convert an arbitrary node to dot code. It just determines the node type and calls the
@@ -120,6 +139,8 @@ def _convert_node_to_dot(ebd_graph: EbdGraph, node: str, indent: str) -> str:
120139
return _convert_start_node_to_dot(ebd_graph, node, indent)
121140
case EmptyNode():
122141
return _convert_empty_node_to_dot(ebd_graph, node, indent)
142+
case TransitionNode():
143+
return _convert_transition_node_to_dot(ebd_graph, node, indent)
123144
case _:
124145
raise ValueError(f"Unknown node type: {ebd_graph.graph.nodes[node]['node']}")
125146

src/rebdhuhn/models/ebd_graph.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,33 @@ def get_key(self) -> str:
150150
return "Empty"
151151

152152

153+
@attrs.define(auto_attribs=True, kw_only=True, frozen=True)
154+
class TransitionNode(EbdGraphNode):
155+
"""
156+
A transition node is a leaf of the Entscheidungsbaum tree.
157+
It has exactly one subsequent step and does neither contain a decision nor an outcome.
158+
Its fields are the same as the DecisionNode, but they are functionally different.
159+
It's related to an EbdCheckResult/SubRow which has a check_result.result None and only 1 subsequent step number.
160+
"""
161+
162+
step_number: str = attrs.field(validator=attrs.validators.matches_re(r"\d+\*?"))
163+
"""
164+
number of the Prüfschritt, e.g. '105', '2' or '6*'
165+
"""
166+
question: str = attrs.field(validator=attrs.validators.instance_of(str))
167+
"""
168+
the questions which is asked at this node in the tree
169+
"""
170+
note: Optional[str] = attrs.field(validator=attrs.validators.optional(attrs.validators.instance_of(str)))
171+
"""
172+
An optional note that explains the purpose, e.g.
173+
'Aufnahme von 0..n Treffern in die neue Trefferliste auf Basis von drei Kriterien'
174+
"""
175+
176+
def get_key(self) -> str:
177+
return self.step_number
178+
179+
153180
@attrs.define(auto_attribs=True, kw_only=True)
154181
class EbdGraphEdge:
155182
"""
@@ -196,6 +223,18 @@ class ToNoEdge(EbdGraphEdge):
196223
"""
197224

198225

226+
@attrs.define(auto_attribs=True, kw_only=True)
227+
class TransitionEdge(EbdGraphEdge):
228+
"""
229+
an edge that connects a TransitionNode to the respective next step
230+
"""
231+
232+
source: TransitionNode = attrs.field(validator=attrs.validators.instance_of(TransitionNode))
233+
"""
234+
ths source which refers to the next step
235+
"""
236+
237+
199238
@attrs.define(auto_attribs=True, kw_only=True)
200239
class EbdGraph:
201240
"""

unittests/__snapshots__/test_table_to_graph.ambr

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1838,3 +1838,35 @@
18381838
}
18391839
'''
18401840
# ---
1841+
# name: TestEbdTableModels.test_table_to_dot_to_svg[E_0594-partly][table_dot_svg_E_0594]
1842+
'''
1843+
digraph D {
1844+
labelloc="t";
1845+
label=<<B><FONT POINT-SIZE="18">GPKE</FONT></B><BR align="left"/><BR/><B><FONT POINT-SIZE="16">MaLo Ident</FONT></B><BR align="left"/><BR/><BR/><BR/>>;
1846+
ratio="compress";
1847+
concentrate=true;
1848+
pack=true;
1849+
rankdir=TB;
1850+
packmode="array";
1851+
size="20,20";
1852+
fontsize=12;
1853+
pad=0.25;
1854+
"Start" [margin="0.2,0.12", shape=box, style="filled,rounded", penwidth=0.0, fillcolor="#8ba2d7", label=<<B>E_0594</B><BR align="left"/><FONT>Prüfende Rolle: <B>VNB</B></FONT><BR align="center"/>>, fontname="Roboto, sans-serif"];
1855+
"270" [margin="0.2,0.12", shape=box, style="filled,rounded", penwidth=0.0, fillcolor="#c2cee9", label=<<B>270: </B>Ist das Identifikationskriterium „Adresse“ in der Anfrage enthalten?<BR align="left"/>>, fontname="Roboto, sans-serif"];
1856+
"275" [margin="0.2,0.12", shape=box, style="filled,rounded", penwidth=0.0, fillcolor="#c2cee9", label=<<B>275: </B>Vollständige [Adressprüfung]<BR align="left"/><FONT>Aufnahme von 0..n Treffern in die Trefferliste auf Basis der alleinigen<BR align="left"/>Adressprüfung<BR align="left"/></FONT>>, fontname="Roboto, sans-serif"];
1857+
"280" [margin="0.2,0.12", shape=box, style="filled,rounded", penwidth=0.0, fillcolor="#c2cee9", label=<<B>280: </B>Gibt es in der Trefferliste auf Basis der alleinigen Adressprüfung genau einen<BR align="left"/>Treffer mit dem Identifikationskriterium Adresse?<BR align="left"/>>, fontname="Roboto, sans-serif"];
1858+
"A02" [margin="0.2,0.12", shape=box, style="filled,rounded", penwidth=0.0, fillcolor="#c4cac1", label=<<B>A02</B><BR align="left"/><BR align="left"/><FONT>Cluster Ablehnung blablba<BR align="left"/></FONT>>, fontname="Roboto, sans-serif"];
1859+
"A123" [margin="0.2,0.12", shape=box, style="filled,rounded", penwidth=0.0, fillcolor="#c4cac1", label=<<B>A123</B><BR align="left"/><BR align="left"/><FONT>Cluster keine Ablehnung bliblub<BR align="left"/></FONT>>, fontname="Roboto, sans-serif"];
1860+
1861+
"Start" -> "270" [color="#88a0d6"];
1862+
"270" -> "275" [label=<<B>JA</B>>, color="#88a0d6", fontname="Roboto, sans-serif"];
1863+
"270" -> "280" [label=<<B>NEIN</B>>, color="#88a0d6", fontname="Roboto, sans-serif"];
1864+
"275" -> "280" [color="#88a0d6"];
1865+
"280" -> "A02" [label=<<B>NEIN</B>>, color="#88a0d6", fontname="Roboto, sans-serif"];
1866+
"280" -> "A123" [label=<<B>JA</B>>, color="#88a0d6", fontname="Roboto, sans-serif"];
1867+
1868+
bgcolor="transparent";
1869+
fontname="Roboto, sans-serif";
1870+
}
1871+
'''
1872+
# ---
Lines changed: 100 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)